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
|
MEDIA_DIR=/data/media
|
||||||
BOT_HEALTH_PORT=8081
|
BOT_HEALTH_PORT=8081
|
||||||
BOT_LOG_LEVEL=debug
|
BOT_LOG_LEVEL=debug
|
||||||
SEED_OPERATOR_TELEGRAM_ID=818380985
|
SEED_OPERATOR_USERNAME=admin
|
||||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||||
WEB_PORT=9000
|
WEB_PORT=9000
|
||||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
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:
|
# ARE committed to this private Gitea. Only ignore example overrides:
|
||||||
.env.local
|
.env.local
|
||||||
.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
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
125
README.md
125
README.md
@ -6,24 +6,36 @@ the run history all from a phone home-screen icon.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the
|
||||||
the primary control surface; the Telegram bot has been removed.
|
primary control surface; the Telegram bot has been removed.
|
||||||
|
|
||||||
What's working today:
|
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.
|
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
|
||||||
Mobile-first single-row header with a slide-out drawer; desktop
|
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
|
- **Live QR pairing** — server-side Baileys session feeds the QR
|
||||||
payload directly into the browser via Server-Sent Events. Scan,
|
payload directly into the browser via Server-Sent Events. Scan,
|
||||||
see "✅ Connected" within seconds, auto-redirect.
|
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
|
- **Multi-account, multi-group reminders** — 5-step wizard
|
||||||
(Account → Message → When → Groups → Review) plus per-section edit
|
(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
|
pages so you don't have to walk the wizard end-to-end to fix one
|
||||||
field. Active recurrence picker covers Daily / Weekly / Monthly /
|
field. Recurrence picker covers Daily / Weekly / Monthly / Yearly
|
||||||
Yearly with multi-rule support and per-rule fire-time pickers; the
|
with multi-rule support and per-rule fire-time pickers; the rendered
|
||||||
rendered description reads as plain English ("Every week on Mon,
|
description reads as plain English ("Every week on Mon, Wed, Fri at
|
||||||
Wed, Fri at 09:00") not raw cron.
|
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
|
- **Multi-message stacks** — a reminder can carry multiple ordered
|
||||||
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
||||||
files swap at any time from the Edit Message page.
|
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.
|
as a downloadable file instead of failing silently.
|
||||||
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
||||||
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
|
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 /
|
- **Activity tab** — last 200 runs with status filters (Success /
|
||||||
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
|
Paused / Failed / Archived). Partial runs surface under both Paused
|
||||||
run to keep the main list readable; restore later. Hard-delete
|
and Failed; Skipped runs collapse into Archived. Hard-delete and
|
||||||
always available. Run history survives a reminder deletion.
|
archive both available; run history survives a reminder deletion.
|
||||||
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
||||||
session persistence.** Pair once, the device stays linked across
|
session persistence.** Pair once, the device stays linked across
|
||||||
container restarts.
|
container restarts. Logout-on-delete cleans the operator's
|
||||||
- **All actions audited.** Reminder run history queryable from the
|
linked-devices list on the WhatsApp side too.
|
||||||
UI; per-run target results (sent / failed / skipped) preserved
|
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
|
||||||
even when the underlying group is removed.
|
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
|
## Host requirements
|
||||||
|
|
||||||
@ -79,24 +101,28 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
|
|||||||
# 1. Configure env
|
# 1. Configure env
|
||||||
cp envs/.env.example .env.development
|
cp envs/.env.example .env.development
|
||||||
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
|
# 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
|
# 2. Bring up the stack, install deps
|
||||||
NO_SUDO=1 scripts/dev.sh up
|
NO_SUDO=1 scripts/dev.sh up
|
||||||
NO_SUDO=1 scripts/dev.sh pnpm install
|
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 migrate
|
||||||
NO_SUDO=1 scripts/db.sh seed
|
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
|
# Local: http://localhost:9000
|
||||||
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
# LAN: http://<host-ip>:9000
|
||||||
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
# Public: https://wabot.04080616.xyz
|
||||||
```
|
```
|
||||||
|
|
||||||
Pair an account: `/accounts` → "New Account" → enter a label →
|
Inside the app: `/settings/users` → Add user → invite teammates with
|
||||||
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
`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
|
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
||||||
Screen". Launches fullscreen.
|
Screen". Launches fullscreen.
|
||||||
@ -104,10 +130,22 @@ Screen". Launches fullscreen.
|
|||||||
`NO_SUDO=1` is the right setting if your user is in the `docker`
|
`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`.
|
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
|
## Manual test runbook
|
||||||
|
|
||||||
End-to-end checks that unit tests can't cover (live Baileys,
|
End-to-end checks that unit tests can't cover (live Baileys,
|
||||||
WhatsApp delivery, swipe gestures):
|
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).
|
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@ -118,11 +156,14 @@ WhatsApp delivery, swipe gestures):
|
|||||||
- `packages/db/` — Drizzle schema and migrations
|
- `packages/db/` — Drizzle schema and migrations
|
||||||
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
||||||
timezones, WhatsApp media classifier)
|
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
|
- `docs/superpowers/plans/` — implementation plans
|
||||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
||||||
`web.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
|
## 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/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/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/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).
|
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
|
## Deferred
|
||||||
|
|
||||||
- **Standalone media library** browser (currently media is uploaded
|
- **Standalone media library** browser (currently media is uploaded
|
||||||
per-reminder).
|
per-reminder).
|
||||||
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
||||||
- **Auth** (passkeys / email-password) — bring back if URL exposure
|
- **Search-as-you-type in the wizard's groups picker** — at 3 000+
|
||||||
becomes a concern. Today the app trusts whatever's in front of the
|
groups per account the picker still loads the alphabetical
|
||||||
reverse proxy.
|
top-200; operators with >200 groups need to use the list page's
|
||||||
- **Multi-operator** — schema supports `operator_id` on every row,
|
search to find anything past 'L'.
|
||||||
but the seed runs as a single operator and there's no /signup or
|
- **Self-service password reset** (email link, etc.) — out of scope
|
||||||
invite flow yet.
|
for v1; admins use the Users page.
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Notification } from "pg";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { handleStartPairing } from "./pair-handler.js";
|
import { handleStartPairing } from "./pair-handler.js";
|
||||||
import { handleUnpair } from "./unpair-handler.js";
|
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
||||||
import { handleSyncGroups } from "./sync-groups-handler.js";
|
import { handleSyncGroups } from "./sync-groups-handler.js";
|
||||||
import { handleSendTest } from "./send-test-handler.js";
|
import { handleSendTest } from "./send-test-handler.js";
|
||||||
import {
|
import {
|
||||||
@ -14,6 +14,11 @@ import {
|
|||||||
export type BotCommand =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
|
// Like unpair, but tells WhatsApp to drop this device from the
|
||||||
|
// user's linked-devices list first via socket.logout(). The web
|
||||||
|
// action calls this immediately before deleting the row so the
|
||||||
|
// operator's phone doesn't keep showing a phantom linked device.
|
||||||
|
| { type: "account.delete"; accountId: string }
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||||
@ -74,6 +79,9 @@ export function registerDefaultHandlers(): void {
|
|||||||
registerHandler("account.unpair", async (cmd) => {
|
registerHandler("account.unpair", async (cmd) => {
|
||||||
await handleUnpair(cmd.accountId);
|
await handleUnpair(cmd.accountId);
|
||||||
});
|
});
|
||||||
|
registerHandler("account.delete", async (cmd) => {
|
||||||
|
await handleDelete(cmd.accountId);
|
||||||
|
});
|
||||||
registerHandler("account.sync_groups", async (cmd) => {
|
registerHandler("account.sync_groups", async (cmd) => {
|
||||||
await handleSyncGroups(cmd.accountId);
|
await handleSyncGroups(cmd.accountId);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,6 +10,16 @@ export type WebEvent =
|
|||||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||||
| { type: "session.disconnected"; accountId: string }
|
| { type: "session.disconnected"; accountId: string }
|
||||||
| { type: "session.timeout"; accountId: string }
|
| { type: "session.timeout"; accountId: string }
|
||||||
|
// Operator scanned the QR with a phone that's already linked to another
|
||||||
|
// account row. We park the new pairing instead of letting two account
|
||||||
|
// rows fight over the same WhatsApp device. existingLabel surfaces in
|
||||||
|
// the UI so the operator knows which account already owns the phone.
|
||||||
|
| {
|
||||||
|
type: "session.duplicate";
|
||||||
|
accountId: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
existingLabel: string;
|
||||||
|
}
|
||||||
| { type: "groups.synced"; accountId: string; count: number }
|
| { type: "groups.synced"; accountId: string; count: number }
|
||||||
| {
|
| {
|
||||||
type: "reminder.fired";
|
type: "reminder.fired";
|
||||||
|
|||||||
@ -10,11 +10,23 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
|
|||||||
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { pgNotifyWeb } from "./notify.js";
|
import { pgNotifyWeb } from "./notify.js";
|
||||||
|
import {
|
||||||
|
decidePairListenerOnClose,
|
||||||
|
findDuplicateExistingAccount,
|
||||||
|
nextWarmingUpAfterEvent,
|
||||||
|
} from "./pair-state.js";
|
||||||
|
|
||||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
const offByAccount = new Map<string, () => void>();
|
const offByAccount = new Map<string, () => void>();
|
||||||
const lastQrPayload = new Map<string, string>();
|
const lastQrPayload = new Map<string, string>();
|
||||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
|
// "Warming" set: while present, the just-attached listener will ignore
|
||||||
|
// close events. Cleared the moment a qr/open arrives. This prevents the
|
||||||
|
// old session's close (broadcast asynchronously by sessionManager after
|
||||||
|
// our await sessionManager.stop() returns) from being mis-read as the
|
||||||
|
// NEW session timing out — which manifested as: get QR → go back →
|
||||||
|
// click Pair again → instantly see "Pairing timed out".
|
||||||
|
const pairingWarmingUp = new Set<string>();
|
||||||
|
|
||||||
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
@ -34,6 +46,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
|||||||
pairTimeouts.delete(accountId);
|
pairTimeouts.delete(accountId);
|
||||||
}
|
}
|
||||||
lastQrPayload.delete(accountId);
|
lastQrPayload.delete(accountId);
|
||||||
|
pairingWarmingUp.delete(accountId);
|
||||||
if (sessionManager.hasSession(accountId)) {
|
if (sessionManager.hasSession(accountId)) {
|
||||||
await sessionManager.stop(accountId);
|
await sessionManager.stop(accountId);
|
||||||
}
|
}
|
||||||
@ -80,10 +93,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
.set({ lastQrPng: null })
|
.set({ lastQrPng: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
|
|
||||||
|
// Mark the new attempt as warming up. Cleared by the first qr/open we
|
||||||
|
// observe; while set, any close event is treated as the leaked tail of
|
||||||
|
// the previous session being torn down (see comment near
|
||||||
|
// `pairingWarmingUp` declaration).
|
||||||
|
pairingWarmingUp.add(accountId);
|
||||||
|
|
||||||
const off = sessionManager.on(async (id, _state, event) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
if (event.type === "qr") {
|
if (event.type === "qr") {
|
||||||
|
pairingWarmingUp.delete(id);
|
||||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||||
// burst. Different strings (a fresh QR) always pass through, so
|
// burst. Different strings (a fresh QR) always pass through, so
|
||||||
// the user gets a new QR as soon as Baileys generates one.
|
// the user gets a new QR as soon as Baileys generates one.
|
||||||
@ -102,6 +122,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
});
|
});
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
|
pairingWarmingUp.delete(id);
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@ -109,6 +130,53 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
lastQrPayload.delete(id);
|
lastQrPayload.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
|
|
||||||
|
// Duplicate-pair guard. Operator scanned the QR with a phone
|
||||||
|
// that's already linked to another account row. Letting both
|
||||||
|
// rows claim the same WhatsApp device confuses Baileys and
|
||||||
|
// turns sends into a coin flip — abandon this pairing and
|
||||||
|
// surface a clear message to the UI.
|
||||||
|
const siblings = await db.query.whatsappAccounts.findMany({
|
||||||
|
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
|
||||||
|
columns: { id: true, phoneNumber: true, label: true },
|
||||||
|
});
|
||||||
|
const dup = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: id,
|
||||||
|
currentPhoneNumber: event.phoneNumber,
|
||||||
|
siblings,
|
||||||
|
});
|
||||||
|
if (dup) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
accountId: id,
|
||||||
|
phoneNumber: event.phoneNumber,
|
||||||
|
existingAccountId: dup.existingAccountId,
|
||||||
|
existingLabel: dup.existingLabel,
|
||||||
|
},
|
||||||
|
"pair: duplicate phone — abandoning new pairing",
|
||||||
|
);
|
||||||
|
// Stop the duplicate session, scrub the partial auth blob,
|
||||||
|
// and reset the row's status. We DO NOT logout() here — the
|
||||||
|
// original account's session remains valid and the operator
|
||||||
|
// hasn't actually added a new linked device on the phone yet
|
||||||
|
// (it'd just be the freshly-completed scan, which Baileys
|
||||||
|
// hasn't yet committed to the WhatsApp side).
|
||||||
|
await sessionManager.stop(id, { intentional: true });
|
||||||
|
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
|
||||||
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
|
||||||
|
.where(eq(whatsappAccounts.id, id));
|
||||||
|
await pgNotifyWeb({
|
||||||
|
type: "session.duplicate",
|
||||||
|
accountId: id,
|
||||||
|
phoneNumber: event.phoneNumber!,
|
||||||
|
existingLabel: dup.existingLabel,
|
||||||
|
});
|
||||||
|
off();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = sessionManager.getSession(id);
|
const session = sessionManager.getSession(id);
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
if (session) {
|
if (session) {
|
||||||
@ -134,27 +202,42 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
count: synced,
|
count: synced,
|
||||||
});
|
});
|
||||||
off();
|
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"
|
// After the user scans, WhatsApp tells Baileys to "restart"
|
||||||
// the connection. The socket closes with status 515 and the
|
// the connection. The socket closes with status 515 and the
|
||||||
// session-manager will reopen it with the new credentials —
|
// session-manager will reopen it with the new credentials —
|
||||||
// the next `open` event is what completes the pairing.
|
// the next `open` event finishes the pairing. Keep the
|
||||||
// This is NOT a failure: keep the listener attached so we see
|
// listener attached and don't surface a timeout to the UI.
|
||||||
// that subsequent `open` event, and don't surface a timeout
|
//
|
||||||
// to the UI. The DB row stays in `pending` until `open`.
|
// 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(
|
logger.info(
|
||||||
{ accountId: id },
|
{ accountId: id },
|
||||||
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
||||||
);
|
);
|
||||||
// The session-manager handles the actual reconnect; nothing to
|
return;
|
||||||
// do here other than NOT tear our listener / DB state down.
|
}
|
||||||
} else if (event.type === "close") {
|
// decision === "treat-as-timeout": ephemeral close on a live
|
||||||
// During the pairing window, any other close means the QR window
|
// attempt. Park the row as `unpaired` and push session.timeout
|
||||||
// ended without a successful link — Baileys' default is to
|
// so the operator sees the "Re-pair" affordance.
|
||||||
// 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.
|
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
decideOnPairClose,
|
decideOnPairClose,
|
||||||
decideOnPairTimeout,
|
decideOnPairTimeout,
|
||||||
|
decidePairListenerOnClose,
|
||||||
|
findDuplicateExistingAccount,
|
||||||
|
nextWarmingUpAfterEvent,
|
||||||
shouldAutoReconnect,
|
shouldAutoReconnect,
|
||||||
} from "./pair-state.js";
|
} from "./pair-state.js";
|
||||||
|
|
||||||
@ -82,3 +85,225 @@ describe("shouldAutoReconnect", () => {
|
|||||||
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
|
||||||
|
it("ignores a close while warming up — even if also restartRequired", () => {
|
||||||
|
// The exact bug: stop() was awaited, listener attached, then the OLD
|
||||||
|
// session's close arrives and races our new listener. Warming-up
|
||||||
|
// wins over every other branch so the UI never sees a spurious
|
||||||
|
// session.timeout before the new QR is rendered.
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
|
||||||
|
).toBe("ignore-leaked-close");
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
||||||
|
).toBe("ignore-leaked-close");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
|
||||||
|
// Refs exhausted, network blip, etc. — operator gets the
|
||||||
|
// "Pairing timed out" screen and a Re-pair affordance.
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
|
||||||
|
).toBe("treat-as-timeout");
|
||||||
|
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
|
||||||
|
// Status 515 close: the session-manager will reconnect and the next
|
||||||
|
// `open` finishes the pair. We must NOT push session.timeout here.
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
|
||||||
|
).toBe("post-pair-restart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
|
||||||
|
// Defense-in-depth: if Baileys' restart-required close from the OLD
|
||||||
|
// session somehow leaks through, treating it as a real 515 would
|
||||||
|
// KEEP the listener attached forever (no reconnect comes from a
|
||||||
|
// session we just stopped). Ignore it entirely until a fresh qr/open.
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
||||||
|
).toBe("ignore-leaked-close");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
|
||||||
|
it("first qr from the live session clears warming-up", () => {
|
||||||
|
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
|
||||||
|
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first open from the live session clears warming-up", () => {
|
||||||
|
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
|
||||||
|
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
|
||||||
|
// The regression: after the user scans, Baileys closes with status
|
||||||
|
// 515 and the session-manager schedules a stop().then(start())
|
||||||
|
// reconnect. That cleanup-stop emits a SECOND close that arrives
|
||||||
|
// before the new socket reopens. If warming-up isn't re-armed
|
||||||
|
// between the two closes, the second one resolves to
|
||||||
|
// 'treat-as-timeout' and detaches the listener right at the
|
||||||
|
// moment the user actually paired successfully — UI never gets
|
||||||
|
// session.connected.
|
||||||
|
expect(
|
||||||
|
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plain close leaves warming-up unchanged", () => {
|
||||||
|
// The pair-handler decides what to DO with a non-restart close
|
||||||
|
// separately (decidePairListenerOnClose). The warming-up flag
|
||||||
|
// doesn't change as a side effect — the listener either detaches
|
||||||
|
// (treat-as-timeout) or already returned (ignore-leaked-close).
|
||||||
|
expect(
|
||||||
|
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
|
||||||
|
// Full lifecycle the helper has to thread correctly so the user
|
||||||
|
// sees 'Account connected!' instead of 'Pairing timed out'.
|
||||||
|
let warming = true; // freshly attached listener after a re-pair
|
||||||
|
|
||||||
|
// First QR arrives — clears the leak-protection flag.
|
||||||
|
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
|
||||||
|
expect(warming).toBe(false);
|
||||||
|
|
||||||
|
// User scans → Baileys closes with restartRequired=true.
|
||||||
|
// Re-arms because session-manager will run another stop+start.
|
||||||
|
warming = nextWarmingUpAfterEvent({
|
||||||
|
warmingUp: warming,
|
||||||
|
event: "close",
|
||||||
|
restartRequired: true,
|
||||||
|
});
|
||||||
|
expect(warming).toBe(true);
|
||||||
|
|
||||||
|
// The cleanup-stop's second close arrives. The CALLER decides via
|
||||||
|
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
|
||||||
|
expect(
|
||||||
|
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
|
||||||
|
).toBe("ignore-leaked-close");
|
||||||
|
// Flag stays armed because a plain close doesn't change it.
|
||||||
|
warming = nextWarmingUpAfterEvent({
|
||||||
|
warmingUp: warming,
|
||||||
|
event: "close",
|
||||||
|
restartRequired: false,
|
||||||
|
});
|
||||||
|
expect(warming).toBe(true);
|
||||||
|
|
||||||
|
// Fresh socket opens with the new credentials → success.
|
||||||
|
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
|
||||||
|
expect(warming).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
|
||||||
|
const sibling = (id: string, phone: string | null, label: string) => ({
|
||||||
|
id,
|
||||||
|
phoneNumber: phone,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a sibling that already holds this phone number", () => {
|
||||||
|
const r = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: "60123456789",
|
||||||
|
siblings: [
|
||||||
|
sibling("new", null, "scratch"),
|
||||||
|
sibling("existing", "60123456789", "Yiekheng-my"),
|
||||||
|
sibling("other", "60987654321", "WaBot Test"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r).toEqual({
|
||||||
|
existingAccountId: "existing",
|
||||||
|
existingLabel: "Yiekheng-my",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the phone is unique", () => {
|
||||||
|
const r = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: "60123456789",
|
||||||
|
siblings: [
|
||||||
|
sibling("new", null, "scratch"),
|
||||||
|
sibling("other", "60987654321", "WaBot"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
|
||||||
|
// After session-manager.handleEvent runs first it has already
|
||||||
|
// written phone_number on the current row. The check must skip
|
||||||
|
// that row, otherwise EVERY successful pair would match itself
|
||||||
|
// and look like a duplicate.
|
||||||
|
const r = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "self",
|
||||||
|
currentPhoneNumber: "60123456789",
|
||||||
|
siblings: [sibling("self", "60123456789", "Self")],
|
||||||
|
});
|
||||||
|
expect(r).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
|
||||||
|
const siblings = [
|
||||||
|
sibling("new", null, "scratch"),
|
||||||
|
sibling("a", null, "Old A"),
|
||||||
|
sibling("b", "", "Old B"),
|
||||||
|
sibling("c", " ", "Old C"),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: null,
|
||||||
|
siblings,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: "",
|
||||||
|
siblings,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: " ",
|
||||||
|
siblings,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises whitespace on both sides before comparing", () => {
|
||||||
|
const r = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: " 60123456789 ",
|
||||||
|
siblings: [sibling("existing", "60123456789", "Existing")],
|
||||||
|
});
|
||||||
|
expect(r?.existingAccountId).toBe("existing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
|
||||||
|
// Defensive: this state shouldn't exist in production but the helper
|
||||||
|
// should at least be deterministic so the message is consistent.
|
||||||
|
const r = findDuplicateExistingAccount({
|
||||||
|
currentAccountId: "new",
|
||||||
|
currentPhoneNumber: "60123456789",
|
||||||
|
siblings: [
|
||||||
|
sibling("first", "60123456789", "First"),
|
||||||
|
sibling("second", "60123456789", "Second"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r?.existingAccountId).toBe("first");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -80,3 +80,106 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
|
|||||||
if (current !== "pending") return null;
|
if (current !== "pending") return null;
|
||||||
return { next: "unpaired", clearQrPng: true };
|
return { next: "unpaired", clearQrPng: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide how the pair-handler should react to a `close` event delivered
|
||||||
|
* to its listener. Three outcomes:
|
||||||
|
*
|
||||||
|
* - "ignore-leaked-close": the new attempt is still warming up and
|
||||||
|
* we're seeing the OLD session's tail close. Do nothing — don't
|
||||||
|
* emit timeout to the UI, don't touch the DB row.
|
||||||
|
* - "post-pair-restart": status-515 close from a successful scan.
|
||||||
|
* The session-manager will reconnect; we keep the listener alive
|
||||||
|
* and wait for the subsequent `open` event.
|
||||||
|
* - "treat-as-timeout": a real ephemeral close on a live attempt
|
||||||
|
* (refs exhausted, etc.). Park the row as `unpaired` and push
|
||||||
|
* `session.timeout` to the UI.
|
||||||
|
*
|
||||||
|
* Captures the regression where, after the user pulled up a QR and
|
||||||
|
* navigated back, clicking Pair again would instantly flash "Pairing
|
||||||
|
* timed out" because the await on stop() returned before
|
||||||
|
* sessionManager.handleEvent finished broadcasting the old session's
|
||||||
|
* close — and the new listener was already attached.
|
||||||
|
*/
|
||||||
|
export type PairListenerCloseDecision =
|
||||||
|
| "ignore-leaked-close"
|
||||||
|
| "post-pair-restart"
|
||||||
|
| "treat-as-timeout";
|
||||||
|
|
||||||
|
export function decidePairListenerOnClose(input: {
|
||||||
|
warmingUp: boolean;
|
||||||
|
restartRequired?: boolean;
|
||||||
|
}): PairListenerCloseDecision {
|
||||||
|
if (input.warmingUp) return "ignore-leaked-close";
|
||||||
|
if (input.restartRequired) return "post-pair-restart";
|
||||||
|
return "treat-as-timeout";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step the pair-listener's warming-up flag forward through one Baileys
|
||||||
|
* event. Captures three rules in one place so they're test-locked:
|
||||||
|
*
|
||||||
|
* - First `qr` / `open` from the live session clears warming-up
|
||||||
|
* (we've seen real session activity, future closes are real).
|
||||||
|
* - `close + restartRequired` (post-pair-success / status 515)
|
||||||
|
* RE-ARMS warming-up. The session-manager will schedule a
|
||||||
|
* `stop().then(start())` reconnect; that stop emits a second close
|
||||||
|
* before the new socket reopens. Without re-arming, the leaked
|
||||||
|
* close from the cleanup-stop reaches us with warming-up=false and
|
||||||
|
* resolves to `treat-as-timeout` — detaching the listener right at
|
||||||
|
* the moment the user actually paired successfully (regression).
|
||||||
|
* - Any other `close` keeps warming-up unchanged (the listener
|
||||||
|
* either ignored it because we're warming, or processed it as a
|
||||||
|
* real timeout / restart and is leaving the loop anyway).
|
||||||
|
*/
|
||||||
|
export function nextWarmingUpAfterEvent(input: {
|
||||||
|
warmingUp: boolean;
|
||||||
|
event: "qr" | "open" | "close";
|
||||||
|
restartRequired?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
if (input.event === "qr" || input.event === "open") return false;
|
||||||
|
if (input.event === "close" && input.restartRequired) return true;
|
||||||
|
return input.warmingUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a freshly-paired account is a duplicate of an
|
||||||
|
* existing account row owned by the same operator. The operator
|
||||||
|
* cannot legitimately link the same WhatsApp number to two account
|
||||||
|
* rows — Baileys keeps one auth blob per phone and the second row
|
||||||
|
* would just hijack the first's session.
|
||||||
|
*
|
||||||
|
* Inputs:
|
||||||
|
* - `currentAccountId` the row that just received the open event
|
||||||
|
* - `currentPhoneNumber` the JID-derived phone string (or null)
|
||||||
|
* - `siblings` every other operator-owned account row
|
||||||
|
*
|
||||||
|
* Returns `null` if the phone is unique (proceed normally), or a
|
||||||
|
* descriptor with the existing-row's id+label so the caller can park
|
||||||
|
* the duplicate row and surface a clear "already linked" message to
|
||||||
|
* the UI. A null/empty phone never reports a duplicate (we'd be
|
||||||
|
* comparing apples and we'd block legitimate first pairs that
|
||||||
|
* haven't received the WID yet).
|
||||||
|
*/
|
||||||
|
export interface DuplicatePairInput {
|
||||||
|
currentAccountId: string;
|
||||||
|
currentPhoneNumber: string | null | undefined;
|
||||||
|
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
|
||||||
|
}
|
||||||
|
export interface DuplicatePairFinding {
|
||||||
|
existingAccountId: string;
|
||||||
|
existingLabel: string;
|
||||||
|
}
|
||||||
|
export function findDuplicateExistingAccount(
|
||||||
|
input: DuplicatePairInput,
|
||||||
|
): DuplicatePairFinding | null {
|
||||||
|
const phone = (input.currentPhoneNumber ?? "").trim();
|
||||||
|
if (!phone) return null;
|
||||||
|
for (const s of input.siblings) {
|
||||||
|
if (s.id === input.currentAccountId) continue;
|
||||||
|
if ((s.phoneNumber ?? "").trim() === phone) {
|
||||||
|
return { existingAccountId: s.id, existingLabel: s.label };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
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 });
|
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();
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
|
||||||
|
// Repro: three pg-boss jobs arrive in the same microsecond. All
|
||||||
|
// three pass the OUTER recent-run check (no run exists yet) and
|
||||||
|
// queue up on the per-account mutex. The first acquires, INSERTs
|
||||||
|
// a run, sends. The second acquires AFTER the first finished —
|
||||||
|
// its inner check now sees the just-inserted run and must bail,
|
||||||
|
// otherwise the message would be sent twice (or three times for
|
||||||
|
// the third job). Without the inner check this regression
|
||||||
|
// produced "qwerd msg three times" in production.
|
||||||
|
getReminderMock.mockResolvedValue({
|
||||||
|
id: "r-1",
|
||||||
|
accountId: "acct-A",
|
||||||
|
status: "active",
|
||||||
|
targets: [],
|
||||||
|
messages: [],
|
||||||
|
createdBy: "op-1",
|
||||||
|
scheduleKind: "one_off",
|
||||||
|
rrule: null,
|
||||||
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
|
name: "Test",
|
||||||
|
});
|
||||||
|
// First call (outer check) returns no recent run → mutex acquired.
|
||||||
|
// Second call (inner check inside fireReminderInner) returns a
|
||||||
|
// freshly-inserted run from the concurrent winner, so the INSERT
|
||||||
|
// path bails. We never reach the .insert(reminderRuns) builder so
|
||||||
|
// the test passes by virtue of the inner-check log + early return.
|
||||||
|
findExistingRunMock
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "run-just-inserted-by-the-other-worker",
|
||||||
|
reminderId: "r-1",
|
||||||
|
firedAt: new Date(),
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireReminder({ reminderId: "r-1" });
|
||||||
|
|
||||||
|
// The mutex DID get acquired (we got past the outer check), but
|
||||||
|
// the inner check should have stopped us before any side effects.
|
||||||
|
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
|
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
|
||||||
// Two pg-boss jobs landing within microseconds for the same
|
// Two pg-boss jobs landing within microseconds for the same
|
||||||
// reminder should NOT both fire. The first creates the run; the
|
// reminder should NOT both fire. The first creates the run; the
|
||||||
|
|||||||
@ -154,6 +154,32 @@ async function fireReminderInner(
|
|||||||
.set({ status: "pending", errorSummary: null })
|
.set({ status: "pending", errorSummary: null })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
} else {
|
} else {
|
||||||
|
// Re-check the dedupe window now that we're inside the per-account
|
||||||
|
// mutex. The outer check in fireReminder() is a fast-path bail-out
|
||||||
|
// but it's TOCTOU: three concurrent jobs can all read "no recent
|
||||||
|
// run" before any of them inserts, so the message gets sent 2-3
|
||||||
|
// times. Inside the mutex, the queue serialises us — by the time
|
||||||
|
// duplicate #2 reaches this point, duplicate #1 has already
|
||||||
|
// INSERTed and we'll find that row here.
|
||||||
|
const recent = await db.query.reminderRuns.findFirst({
|
||||||
|
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
|
||||||
|
dAnd(
|
||||||
|
dEq(r.reminderId, reminder.id),
|
||||||
|
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
|
||||||
|
),
|
||||||
|
orderBy: (r, { desc }) => [desc(r.firedAt)],
|
||||||
|
});
|
||||||
|
if (recent) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
reminderId: reminder.id,
|
||||||
|
recentRunId: recent.id,
|
||||||
|
recentFiredAt: recent.firedAt,
|
||||||
|
},
|
||||||
|
"fire-reminder: duplicate fire detected inside mutex (a run was just inserted by a concurrent job), skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [run] = await db
|
const [run] = await db
|
||||||
.insert(reminderRuns)
|
.insert(reminderRuns)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
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 type { PgBoss } from "pg-boss";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { db } from "../db.js";
|
||||||
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
||||||
|
|
||||||
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
||||||
|
|
||||||
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
||||||
// 'stately' = at most 1 job per (state, singletonKey). Combined with
|
// 'standard' (the default) lets us enqueue a new fire even when an
|
||||||
// singletonKey="reminder:<id>" on every send, that means a duplicate
|
// older one for the same singletonKey is still 'created'. We need
|
||||||
// schedule call (e.g. operator double-clicked Save, or the
|
// that for the recurring/edit path: when a reminder is rescheduled,
|
||||||
// pg_notify('bot.command') consumer fired twice in the same tick)
|
// scheduleReminderFire() first cancels the stale 'created' job for
|
||||||
// is folded into the existing 'created' job instead of producing a
|
// this reminder and then sends a new one — under 'stately' the
|
||||||
// second run. The default 'standard' policy DOES NOT dedupe by
|
// SECOND send returns null (it dedupes against the first across
|
||||||
// singletonKey — that's how we ended up firing a reminder twice
|
// states), so a reschedule silently dropped the new fire and the
|
||||||
// when two reminder.fire jobs landed within microseconds.
|
// reminder never fired at the new time. Duplicate-fire safety is
|
||||||
// https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies
|
// covered at the handler level by the inner-mutex recent-run check
|
||||||
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" });
|
// 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>(
|
await boss.work<FireReminderPayload>(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{
|
{
|
||||||
@ -43,6 +61,33 @@ export async function scheduleReminderFire(
|
|||||||
reminderId: string,
|
reminderId: string,
|
||||||
scheduledAt: Date,
|
scheduledAt: Date,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
const singletonKey = `reminder:${reminderId}`;
|
||||||
|
// Replace-then-send. Any 'created' (i.e. not yet started) job for
|
||||||
|
// this reminder is the stale next-fire from the previous schedule
|
||||||
|
// attempt; nuke it so the new schedule wins. Active/completed jobs
|
||||||
|
// are left alone — those represent in-flight or already-fired runs
|
||||||
|
// and the handler-level dedupe handles overlap.
|
||||||
|
try {
|
||||||
|
const cancelled = await db.execute(
|
||||||
|
sql`UPDATE pgboss.job
|
||||||
|
SET state = 'cancelled', completed_on = now()
|
||||||
|
WHERE name = ${REMINDER_FIRE_QUEUE}
|
||||||
|
AND singleton_key = ${singletonKey}
|
||||||
|
AND state = 'created'
|
||||||
|
RETURNING id`,
|
||||||
|
);
|
||||||
|
if (cancelled.rows.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ reminderId, cancelled: cancelled.rows.length },
|
||||||
|
"reminder.fire: cancelled stale created jobs before reschedule",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If the cancellation step fails, log but still try to send. Worst
|
||||||
|
// case we end up with two created jobs and the handler-level
|
||||||
|
// recent-run dedupe drops the duplicate fire.
|
||||||
|
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
|
||||||
|
}
|
||||||
const id = await boss.send(
|
const id = await boss.send(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{ reminderId },
|
{ reminderId },
|
||||||
@ -51,8 +96,10 @@ export async function scheduleReminderFire(
|
|||||||
retryLimit: 3,
|
retryLimit: 3,
|
||||||
retryDelay: 30,
|
retryDelay: 30,
|
||||||
retryBackoff: true,
|
retryBackoff: true,
|
||||||
// Use the reminderId as a singleton key so re-scheduling cancels the old job
|
// Singleton key kept on the job row for diagnostics + the
|
||||||
singletonKey: `reminder:${reminderId}`,
|
// pre-send cancel above, even though 'standard' policy doesn't
|
||||||
|
// dedupe by it.
|
||||||
|
singletonKey,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
||||||
|
|||||||
@ -7,35 +7,45 @@ import { logger } from "../logger.js";
|
|||||||
export async function syncGroupsForAccount(
|
export async function syncGroupsForAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
socket: WASocket,
|
socket: WASocket,
|
||||||
): Promise<{ synced: number; removed: number }> {
|
): Promise<{ synced: number; archived: number }> {
|
||||||
const meta = await socket.groupFetchAllParticipating();
|
const meta = await socket.groupFetchAllParticipating();
|
||||||
const entries = Object.values(meta);
|
const entries = Object.values(meta);
|
||||||
const liveJids = entries.map((g) => g.id);
|
const liveJids = entries.map((g) => g.id);
|
||||||
|
|
||||||
// Remove DB rows for groups that are no longer in the live participant list
|
// Mark DB rows as archived when they're no longer in the live
|
||||||
// (group was deleted, bot was removed, etc.). Only run the delete when we
|
// participant list (group deleted, bot removed, etc). We don't
|
||||||
// got at least one live group back — an empty result is more likely a
|
// physically DELETE because reminder_targets.group_id is a NOT
|
||||||
// transient WA fetch failure than a genuine "all groups gone" signal, and
|
// NULL FK to this row — a hard delete throws "violates foreign
|
||||||
// we don't want to nuke valid data on a hiccup.
|
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
|
||||||
let removed: { id: string }[] = [];
|
// 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) {
|
if (liveJids.length > 0) {
|
||||||
removed = await db
|
const rows = await db
|
||||||
.delete(whatsappGroups)
|
.update(whatsappGroups)
|
||||||
|
.set({ isArchived: true, lastSyncedAt: new Date() })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(whatsappGroups.accountId, accountId),
|
eq(whatsappGroups.accountId, accountId),
|
||||||
notInArray(whatsappGroups.waGroupJid, liveJids),
|
notInArray(whatsappGroups.waGroupJid, liveJids),
|
||||||
|
eq(whatsappGroups.isArchived, false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning({ id: whatsappGroups.id });
|
.returning({ id: whatsappGroups.id });
|
||||||
|
archived = rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId },
|
{ accountId },
|
||||||
"group-sync: empty fetch — skipping 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) => ({
|
const rows = entries.map((g) => ({
|
||||||
@ -56,12 +66,16 @@ export async function syncGroupsForAccount(
|
|||||||
name: sql`excluded.name`,
|
name: sql`excluded.name`,
|
||||||
participantCount: sql`excluded.participant_count`,
|
participantCount: sql`excluded.participant_count`,
|
||||||
lastSyncedAt: sql`excluded.last_synced_at`,
|
lastSyncedAt: sql`excluded.last_synced_at`,
|
||||||
|
// If a previously-archived group reappears in the live list
|
||||||
|
// (operator was re-added, group was un-deleted, etc.), flip
|
||||||
|
// the flag back so it shows up in the picker again.
|
||||||
|
isArchived: sql`excluded.is_archived`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId, count: rows.length, removed: removed.length },
|
{ accountId, count: rows.length, archived },
|
||||||
"group-sync: synced",
|
"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);
|
this.sessions.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell WhatsApp to remove this device from the linked-devices list,
|
||||||
|
* then close the socket. Used by the delete-account flow so the
|
||||||
|
* operator's phone doesn't keep showing a phantom "linked device"
|
||||||
|
* pointing at a row that no longer exists. Best-effort: if the
|
||||||
|
* socket is already torn down or the logout RPC fails (network
|
||||||
|
* blip, already-disconnected, etc.) we still proceed to close +
|
||||||
|
* teardown — no point stranding the delete because WhatsApp didn't
|
||||||
|
* acknowledge.
|
||||||
|
*/
|
||||||
|
async logoutAndStop(accountId: string): Promise<void> {
|
||||||
|
const timer = this.reconnectTimers.get(accountId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.reconnectTimers.delete(accountId);
|
||||||
|
}
|
||||||
|
const session = this.sessions.get(accountId);
|
||||||
|
if (!session) return;
|
||||||
|
// Suppress reconnect/handleEvent bookkeeping for the close that
|
||||||
|
// logout() emits — the row is about to be deleted entirely so
|
||||||
|
// status writes are pointless.
|
||||||
|
this.intentionalStops.add(accountId);
|
||||||
|
try {
|
||||||
|
await session.socket.logout();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{ err, accountId },
|
||||||
|
"session-manager: socket.logout() failed (continuing with teardown)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await session.close();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
|
||||||
|
}
|
||||||
|
this.sessions.delete(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
||||||
}
|
}
|
||||||
|
|||||||
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: {
|
experimental: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
serverActions: {
|
serverActions: {
|
||||||
|
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
||||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||||
// the action; the per-kind WhatsApp validator
|
// the action; the per-kind WhatsApp validator
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@serwist/next": "^9.5.11",
|
"@serwist/next": "^9.5.11",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
@ -44,6 +45,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@ -172,8 +172,16 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
|
|||||||
.update(whatsappAccounts)
|
.update(whatsappAccounts)
|
||||||
.set({ status: "unpaired", phoneNumber: null })
|
.set({ status: "unpaired", phoneNumber: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
// Wipe synced groups too — they belong to a different WA login now.
|
// Soft-archive synced groups instead of DELETEing. Hard delete
|
||||||
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
|
// 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");
|
||||||
revalidatePath(`/accounts/${accountId}`);
|
revalidatePath(`/accounts/${accountId}`);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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)),
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||||
});
|
});
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
// Stop any live session / clean session files first.
|
// Tell the bot to logout() over the live socket FIRST (so WhatsApp
|
||||||
await pgNotifyBot({ type: "account.unpair", accountId });
|
// 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.
|
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
|
||||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
||||||
revalidatePath("/accounts");
|
revalidatePath("/accounts");
|
||||||
|
|||||||
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" };
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupId = parsed.data.groupId;
|
||||||
|
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
|
||||||
|
if (groupRl.limited) {
|
||||||
|
return { ok: false, error: "Too many tests for this group. Try again later." };
|
||||||
|
}
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const group = await db.query.whatsappGroups.findFirst({
|
const group = await db.query.whatsappGroups.findFirst({
|
||||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
||||||
|
|||||||
@ -271,7 +271,7 @@ const createReminderSchema = z
|
|||||||
path: ["messages"],
|
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",
|
message: "Delivery window start must be earlier than end",
|
||||||
path: ["deliveryWindowStartHour"],
|
path: ["deliveryWindowStartHour"],
|
||||||
});
|
});
|
||||||
@ -328,7 +328,11 @@ export async function createReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
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 parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -442,7 +446,11 @@ export async function updateReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
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 parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -563,6 +571,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
|
|||||||
export async function resumeReminderRunAction(input: {
|
export async function resumeReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<ResumeReminderRunResult> {
|
}): Promise<ResumeReminderRunResult> {
|
||||||
|
const ip =
|
||||||
|
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
||||||
|
if (rl.limited) {
|
||||||
|
return { ok: false, error: "Too many requests. Try again later." };
|
||||||
|
}
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -613,6 +627,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
|
|||||||
export async function cancelReminderRunAction(input: {
|
export async function cancelReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<CancelReminderRunResult> {
|
}): Promise<CancelReminderRunResult> {
|
||||||
|
const ip =
|
||||||
|
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
||||||
|
if (rl.limited) {
|
||||||
|
return { ok: false, error: "Too many requests. Try again later." };
|
||||||
|
}
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
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,
|
ArrowLeftIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
RefreshCwIcon,
|
|
||||||
Users2Icon,
|
Users2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -16,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listGroupsForAccount } from "@/lib/queries";
|
import { listGroupsForAccount } from "@/lib/queries";
|
||||||
|
import { RefreshGroupsClient } from "./refresh-groups-client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -57,13 +57,7 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Refresh button — no-op placeholder, wired in Task 17 */}
|
<RefreshGroupsClient accountId={account.id} />
|
||||||
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
|
|
||||||
<Button type="submit" variant="outline" size="sm" className="shrink-0">
|
|
||||||
<RefreshCwIcon />
|
|
||||||
Refresh Groups
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
@ -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 { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
Trash2Icon,
|
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@ -10,7 +9,6 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
PowerOffIcon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -20,23 +18,12 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getAccount } from "@/lib/queries";
|
import { getAccount } from "@/lib/queries";
|
||||||
import {
|
import { pairAccountAction } from "@/actions/accounts";
|
||||||
unpairAccountAction,
|
import { DeleteAccountCard } from "./delete-account-card";
|
||||||
pairAccountAction,
|
import { UnpairAccountCard } from "./unpair-account-card";
|
||||||
deleteAccountAction,
|
|
||||||
} from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface AccountDetailPageProps {
|
interface AccountDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -156,102 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Unpair — transparent <button> overlay opens the dialog
|
<UnpairAccountCard accountId={account.id} accountLabel={account.label} />
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete — transparent <button> overlay opens the dialog. */}
|
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
|
||||||
<Dialog>
|
|
||||||
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Remove the account and all its reminders, groups, and history
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
|
||||||
</CardContent>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Delete account"
|
|
||||||
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
/>
|
|
||||||
</DialogTrigger>
|
|
||||||
</Card>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{account.label}</strong> will be removed along with its
|
|
||||||
synced groups, scheduled reminders, and all run history. This cannot be
|
|
||||||
undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter showCloseButton>
|
|
||||||
<form action={deleteAccountAction}>
|
|
||||||
<input type="hidden" name="accountId" value={account.id} />
|
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
|
||||||
<Trash2Icon />
|
|
||||||
Yes, delete
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -38,7 +29,6 @@ import { getSeededOperator } from "@/lib/operator";
|
|||||||
import { listActivityRuns } from "@/lib/queries";
|
import { listActivityRuns } from "@/lib/queries";
|
||||||
import {
|
import {
|
||||||
archiveRunAction,
|
archiveRunAction,
|
||||||
clearHistoryAction,
|
|
||||||
deleteRunAction,
|
deleteRunAction,
|
||||||
unarchiveRunAction,
|
unarchiveRunAction,
|
||||||
} from "@/actions/history";
|
} from "@/actions/history";
|
||||||
@ -106,24 +96,24 @@ function RunStatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterValue =
|
type FilterValue = "success" | "paused" | "failed" | "archived";
|
||||||
| "all"
|
|
||||||
| "success"
|
|
||||||
| "paused"
|
|
||||||
| "partial"
|
|
||||||
| "failed"
|
|
||||||
| "skipped"
|
|
||||||
| "archived";
|
|
||||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "success", label: "Success" },
|
{ value: "success", label: "Success" },
|
||||||
{ value: "paused", label: "Paused" },
|
{ value: "paused", label: "Paused" },
|
||||||
{ value: "partial", label: "Partial" },
|
|
||||||
{ value: "failed", label: "Failed" },
|
{ value: "failed", label: "Failed" },
|
||||||
{ value: "skipped", label: "Skipped" },
|
|
||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Partial runs (some recipients ok, some failed) surface under BOTH the
|
||||||
|
// Paused and Failed tabs — the operator wants to see anything that didn't
|
||||||
|
// fully succeed on either page. Skipped runs collapse into Archived since
|
||||||
|
// they're effectively "history that the operator chose not to send".
|
||||||
|
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
|
||||||
|
success: ["success"],
|
||||||
|
paused: ["paused", "partial"],
|
||||||
|
failed: ["failed", "partial"],
|
||||||
|
};
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ filter?: string }>;
|
searchParams: Promise<{ filter?: string }>;
|
||||||
}
|
}
|
||||||
@ -185,76 +175,41 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
const filter: FilterValue =
|
const filter: FilterValue =
|
||||||
sp.filter === "success" ||
|
sp.filter === "success" ||
|
||||||
sp.filter === "paused" ||
|
sp.filter === "paused" ||
|
||||||
sp.filter === "partial" ||
|
|
||||||
sp.filter === "failed" ||
|
sp.filter === "failed" ||
|
||||||
sp.filter === "skipped" ||
|
|
||||||
sp.filter === "archived"
|
sp.filter === "archived"
|
||||||
? sp.filter
|
? sp.filter
|
||||||
: "all";
|
: "success";
|
||||||
const showingArchived = filter === "archived";
|
const showingArchived = filter === "archived";
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
||||||
const filtered =
|
const filtered =
|
||||||
filter === "all" || filter === "archived"
|
filter === "archived"
|
||||||
? runs
|
? runs
|
||||||
: runs.filter((r) => r.status === filter);
|
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
|
||||||
const hasAny = runs.length > 0;
|
const hasAny = runs.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell title="Activity">
|
||||||
title="Activity"
|
{/* Filter tabs span the full row and wrap onto a second line when the
|
||||||
action={
|
viewport can't fit them all. Each trigger has a small basis so they
|
||||||
hasAny && !showingArchived ? (
|
share space evenly while still keeping a readable label on mobile. */}
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
|
||||||
<Trash2Icon />
|
|
||||||
Clear history
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Clear all run history?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This permanently removes every reminder run record, including
|
|
||||||
runs from reminders that have already been deleted. Reminders
|
|
||||||
themselves are not affected.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter showCloseButton>
|
|
||||||
<form action={clearHistoryAction}>
|
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
|
||||||
<Trash2Icon />
|
|
||||||
Yes, clear history
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
|
|
||||||
packed into a phone-width row left every label squeezed to
|
|
||||||
~50px. Wrap the list in an overflow-x scroller so each tab
|
|
||||||
keeps a readable label + comfortable touch target on mobile;
|
|
||||||
on desktop the row fits naturally and no scroll bar appears.
|
|
||||||
Negative margins extend the scroller to the page edges so the
|
|
||||||
first/last tabs don't look clipped against the container. */}
|
|
||||||
<Tabs value={filter}>
|
<Tabs value={filter}>
|
||||||
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
|
||||||
<TabsList>
|
|
||||||
{FILTER_TABS.map(({ value, label }) => (
|
{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 */}
|
{/* 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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{filtered.length > 0 ? (
|
{filtered.length > 0 ? (
|
||||||
@ -422,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ActivityIcon}
|
icon={ActivityIcon}
|
||||||
title={
|
title={
|
||||||
filter === "all"
|
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
|
||||||
? "No activity yet."
|
|
||||||
: showingArchived
|
|
||||||
? "No archived runs."
|
|
||||||
: `No ${filter} runs yet.`
|
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
hasAny
|
hasAny
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
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 {
|
interface RouteContext {
|
||||||
params: Promise<{ accountId: string }>;
|
params: Promise<{ accountId: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { NotificationManager } from "@/components/notification-manager";
|
import { NotificationManager } from "@/components/notification-manager";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "cm WhatsApp Bot",
|
title: "cm WhatsApp Bot",
|
||||||
description: "Self-hosted WhatsApp reminder bot",
|
description: "Self-hosted WhatsApp reminder bot",
|
||||||
applicationName: "cm WhatsApp Bot",
|
applicationName: "cm WhatsApp Bot",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
// PWA wiring: the manifest comes from the dynamic route at
|
// PWA wiring: the manifest comes from the dynamic route at
|
||||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
||||||
@ -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 (
|
return (
|
||||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||||
@ -45,7 +53,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell role={role} username={username}>{children}</AppShell>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||||
<NotificationManager />
|
<NotificationManager />
|
||||||
|
|||||||
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.
|
themselves are not affected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<form action={clearHistoryAction}>
|
<form action={clearHistoryAction}>
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
|
|||||||
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@ -230,12 +230,28 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{reminder.rrule && reminder.scheduledAt ? (
|
||||||
<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" />
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
|
<span className="truncate min-w-0">
|
||||||
{describeRecurrence(
|
{describeRecurrence(
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">One-off</p>
|
<p className="text-xs text-muted-foreground">One-off</p>
|
||||||
|
|||||||
@ -247,15 +247,30 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 text-right space-y-1">
|
{/* Right meta column. Capped at ~14rem so a long
|
||||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
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" />
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
<span className="truncate">
|
||||||
|
{formatWhen(reminder.scheduledAt, tz)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{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" />
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
{describeRecurrence(
|
{describeRecurrence(
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
@ -264,9 +279,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{reminder.groupCount > 0 && (
|
{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" />
|
<UsersIcon className="size-3 shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
{reminder.groupCount}{" "}
|
{reminder.groupCount}{" "}
|
||||||
{reminder.groupCount === 1 ? "group" : "groups"}
|
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||||
</span>
|
</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() {
|
export default async function SettingsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
|
const isAdmin = op.role === "admin";
|
||||||
return (
|
return (
|
||||||
<PageShell title="Settings" narrow>
|
<PageShell title="Settings" narrow>
|
||||||
<Card>
|
<Card>
|
||||||
@ -14,13 +15,15 @@ export default async function SettingsPage() {
|
|||||||
<CardTitle>Operator</CardTitle>
|
<CardTitle>Operator</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<Row label="Display name" value={op.displayName} />
|
<Row label="Username" value={op.username} mono />
|
||||||
<Separator />
|
|
||||||
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Row label="Role" value={op.role} mono />
|
<Row label="Role" value={op.role} mono />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -47,10 +50,6 @@ export default async function SettingsPage() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
cm WhatsApp Bot · self-hosted
|
|
||||||
</p>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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", () => {
|
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<main>page</main>
|
<main>page</main>
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("brand mark on the left links to /", () => {
|
it("brand mark on the left links to /", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
pathnameMock.mockReturnValue(c.path);
|
pathnameMock.mockReturnValue(c.path);
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
||||||
pathnameMock.mockReturnValue("/unknown-route");
|
pathnameMock.mockReturnValue("/unknown-route");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("renders one nav link per NAV_ITEM, in order", () => {
|
it("renders one nav link per NAV_ITEM, in order", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
it("marks the active route's link with aria-current='page'", () => {
|
it("marks the active route's link with aria-current='page'", () => {
|
||||||
pathnameMock.mockReturnValue("/reminders");
|
pathnameMock.mockReturnValue("/reminders");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
// every page. The header uses an exact-match check for "/".
|
// every page. The header uses an exact-match check for "/".
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("drawer header carries the brand wording and a screen-reader description", () => {
|
it("drawer header carries the brand wording and a screen-reader description", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
|
|
||||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</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(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
|
// Theme toggle was dropped from the shell per request; the footer
|
||||||
// appear in the sidebar (we removed it from the mobile drawer).
|
// now carries the Sign out affordance + the signed-in username.
|
||||||
expect(html).toContain('data-testid="theme-toggle"');
|
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", () => {
|
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
// reader users on a wide-window split-screen don't hear two
|
// reader users on a wide-window split-screen don't hear two
|
||||||
// identical announcements when both are visible.
|
// identical announcements when both are visible.
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin" username="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</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
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { MenuIcon } from "lucide-react";
|
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { logoutAction } from "@/actions/auth";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -14,8 +15,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { NAV_ITEMS } from "@/components/nav-config";
|
import {
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
NAV_ITEMS,
|
||||||
|
navItemsForRole,
|
||||||
|
pickActiveNavKey,
|
||||||
|
type NavItem,
|
||||||
|
type NavRole,
|
||||||
|
} from "@/components/nav-config";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mobile header (sm:hidden)
|
// 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
|
// waiting for the page content to render. The menu button on the right
|
||||||
// opens a Sheet with the full nav list and the theme toggle.
|
// opens a Sheet with the full nav list and the theme toggle.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function MobileHeader() {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sign-out button used by both the desktop sidebar footer and the mobile
|
||||||
|
// drawer footer. Server-action under the hood: clears the session
|
||||||
|
// cookie and redirects to /login. Disabled while in flight so a
|
||||||
|
// double-click doesn't fire two redirects.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function SignOutButton({ username }: { username: string | null }) {
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{username && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
Signed in as <em className="italic font-medium text-foreground">{username}</em>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => start(() => logoutAction())}
|
||||||
|
aria-label="Sign out"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOutIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileHeader({
|
||||||
|
items,
|
||||||
|
username,
|
||||||
|
}: {
|
||||||
|
items: NavItem[];
|
||||||
|
username: string | null;
|
||||||
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeKey = pickActiveNavKey(items, pathname);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||||
@ -41,6 +90,10 @@ function MobileHeader() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Use the full list (not the role-filtered one) for the title lookup
|
||||||
|
// so the page title still shows up correctly when a 'user' role hits
|
||||||
|
// a route they wouldn't normally see in the nav (e.g. arrives via a
|
||||||
|
// direct link), even though they can't navigate there from the menu.
|
||||||
const currentItem = NAV_ITEMS.find(({ href }) =>
|
const currentItem = NAV_ITEMS.find(({ href }) =>
|
||||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||||
);
|
);
|
||||||
@ -90,10 +143,10 @@ function MobileHeader() {
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary navigation"
|
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 }) => {
|
{items.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = activeKey === key;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
@ -117,6 +170,10 @@ function MobileHeader() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto border-t border-border p-3">
|
||||||
|
<SignOutButton username={username} />
|
||||||
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</header>
|
</header>
|
||||||
@ -126,8 +183,15 @@ function MobileHeader() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar (desktop only — hidden below sm)
|
// Sidebar (desktop only — hidden below sm)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Sidebar() {
|
function Sidebar({
|
||||||
|
items,
|
||||||
|
username,
|
||||||
|
}: {
|
||||||
|
items: NavItem[];
|
||||||
|
username: string | null;
|
||||||
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeKey = pickActiveNavKey(items, pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
||||||
@ -150,7 +214,7 @@ function Sidebar() {
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
{items.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -172,29 +236,74 @@ function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer: theme toggle */}
|
{/* Footer: signed-in user + sign-out */}
|
||||||
<div className="border-t border-sidebar-border p-3">
|
<div className="border-t border-sidebar-border p-3">
|
||||||
<ThemeToggle />
|
<SignOutButton username={username} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
|
||||||
|
// menu, no nav — just the centered brand mark + name. The user explicitly
|
||||||
|
// asked for nothing else here so the sign-in screen feels like a separate
|
||||||
|
// surface from the authenticated app.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function BareHeader() {
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
|
||||||
|
>
|
||||||
|
cm
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tracking-tight">
|
||||||
|
WhatsApp Bot
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppShell — the outer container
|
// AppShell — the outer container
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** Role of the signed-in user, or null when unauthenticated. */
|
||||||
|
role: NavRole | null;
|
||||||
|
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
|
||||||
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<Sidebar />
|
<Sidebar items={items} username={username} />
|
||||||
|
|
||||||
{/* Mobile header (single row: brand · title · menu) */}
|
{/* Mobile header (single row: brand · title · menu) */}
|
||||||
<MobileHeader />
|
<MobileHeader items={items} username={username} />
|
||||||
|
|
||||||
{/* Main content
|
{/* Main content
|
||||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||||
|
|||||||
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";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export type NavRole = "admin" | "user";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
/** When set, only roles listed here will see this nav entry. */
|
||||||
|
visibleTo?: NavRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
@ -13,5 +24,54 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||||
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
||||||
|
{
|
||||||
|
key: "admin",
|
||||||
|
href: "/settings/users",
|
||||||
|
label: "Admin",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
visibleTo: ["admin"],
|
||||||
|
},
|
||||||
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function navItemsForRole(role: NavRole): NavItem[] {
|
||||||
|
return NAV_ITEMS.filter(
|
||||||
|
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the SINGLE active nav item for a given pathname. Solves the
|
||||||
|
* "Admin and Settings both highlighted on /settings/users" bug:
|
||||||
|
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
|
||||||
|
* Admin entry) AND /settings (its parent). Two items lit up at once
|
||||||
|
* looks broken.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - The Dashboard ('/') item only matches an exact pathname match;
|
||||||
|
* otherwise it would shadow every other route.
|
||||||
|
* - All other items match either an exact pathname or a strict
|
||||||
|
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
|
||||||
|
* its own would also match `/settingsfoo`, which is wrong.
|
||||||
|
* - When two non-root items both match (parent + child), pick the
|
||||||
|
* LONGEST href so the more specific entry wins.
|
||||||
|
*
|
||||||
|
* Returns the active item's `key`, or null if no item matches (e.g.
|
||||||
|
* the user navigated to a route that isn't in the visible nav).
|
||||||
|
*/
|
||||||
|
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
|
||||||
|
let best: NavItem | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.href === "/") {
|
||||||
|
if (pathname === "/") best = item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isMatch =
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
|
if (!isMatch) continue;
|
||||||
|
if (!best || item.href.length > best.href.length) {
|
||||||
|
best = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.key ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@ -18,7 +18,8 @@ type PairingState =
|
|||||||
| { phase: "waiting" }
|
| { phase: "waiting" }
|
||||||
| { phase: "qr"; qrUrl: string }
|
| { phase: "qr"; qrUrl: string }
|
||||||
| { phase: "connected"; phoneNumber: string }
|
| { phase: "connected"; phoneNumber: string }
|
||||||
| { phase: "timeout" };
|
| { phase: "timeout" }
|
||||||
|
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
|
||||||
|
|
||||||
interface PairLiveProps {
|
interface PairLiveProps {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -112,6 +113,15 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
setPairingState({ phase: "timeout" });
|
setPairingState({ phase: "timeout" });
|
||||||
},
|
},
|
||||||
|
"session.duplicate": (data) => {
|
||||||
|
if (data.accountId !== accountId) return;
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
setPairingState({
|
||||||
|
phase: "duplicate",
|
||||||
|
phoneNumber: data.phoneNumber,
|
||||||
|
existingLabel: data.existingLabel,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-redirect on connected
|
// Auto-redirect on connected
|
||||||
@ -234,6 +244,35 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pairingState.phase === "duplicate" && (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
|
||||||
|
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-base font-semibold">Phone already linked</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">
|
||||||
|
+{pairingState.phoneNumber.replace(/^\+/, "")}
|
||||||
|
</span>{" "}
|
||||||
|
is already paired to{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{pairingState.existingLabel}
|
||||||
|
</span>
|
||||||
|
. Each WhatsApp number can only be linked to one account here.
|
||||||
|
Unpair the existing account first, or scan with a different
|
||||||
|
phone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/accounts/${accountId}` as any}>
|
||||||
|
Back to accounts
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
|
|||||||
id="filter-account"
|
id="filter-account"
|
||||||
value={initial.accountId}
|
value={initial.accountId}
|
||||||
onChange={(e) => setParam("accountId", e.target.value)}
|
onChange={(e) => setParam("accountId", e.target.value)}
|
||||||
className="h-8 w-full 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>
|
<option value="">All accounts</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
|
|||||||
@ -67,6 +67,11 @@ export function SwipeableRow({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
||||||
|
// Tracks whether the pointer crossed the click-vs-drag threshold during
|
||||||
|
// the current gesture. If it did, we swallow the synthetic click that
|
||||||
|
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
|
||||||
|
// both swipes the shelf open AND navigates to the link target.
|
||||||
|
const dragMoved = useRef(false);
|
||||||
|
|
||||||
// Close the shelf when the user taps anywhere outside an open row.
|
// Close the shelf when the user taps anywhere outside an open row.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -92,12 +97,17 @@ export function SwipeableRow({
|
|||||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||||
|
dragMoved.current = false;
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (!dragging || !dragStart.current) return;
|
if (!dragging || !dragStart.current) return;
|
||||||
const dx = e.clientX - dragStart.current.x;
|
const dx = e.clientX - dragStart.current.x;
|
||||||
|
// 6 px is the standard threshold below which a touch counts as a tap
|
||||||
|
// rather than a drag. Cross it once and the gesture commits to drag
|
||||||
|
// for the rest of the pointer's lifetime.
|
||||||
|
if (Math.abs(dx) > 6) dragMoved.current = true;
|
||||||
setOffset(clamp(dragStart.current.baseOffset + dx));
|
setOffset(clamp(dragStart.current.baseOffset + dx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +123,28 @@ export function SwipeableRow({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (dragMoved.current) {
|
||||||
|
// The browser fires a synthetic `click` on the element under the
|
||||||
|
// pointer right after pointerup. If our row body wraps a <Link>,
|
||||||
|
// that click navigates away. Add a one-shot capture-phase handler
|
||||||
|
// that swallows the next click ANYWHERE in the row container
|
||||||
|
// before it can reach the anchor's onClick.
|
||||||
|
const swallow = (ev: Event) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
};
|
||||||
|
const node = containerRef.current;
|
||||||
|
if (node) {
|
||||||
|
node.addEventListener("click", swallow, { capture: true, once: true });
|
||||||
|
// Defensive: if for some reason no click fires (e.g. pointerup
|
||||||
|
// outside the element), strip the listener after a tick so it
|
||||||
|
// doesn't accidentally eat a future legitimate click.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
node.removeEventListener("click", swallow, { capture: true });
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragMoved.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -150,6 +182,14 @@ export function SwipeableRow({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
|
// Anchors (and <img>) are natively draggable. When children
|
||||||
|
// contain a <Link> wrapping the card, the browser hijacks the
|
||||||
|
// pointer for a "drag link" operation as soon as the user
|
||||||
|
// moves horizontally, so the swipe gesture never reaches our
|
||||||
|
// pointer handlers. Suppress native drag here once and the
|
||||||
|
// whole row body is unblocked.
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offset}px)`,
|
transform: `translateX(${offset}px)`,
|
||||||
transition: dragging ? "none" : "transform 200ms ease-out",
|
transition: dragging ? "none" : "transform 200ms ease-out",
|
||||||
|
|||||||
@ -8,4 +8,25 @@ const envSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
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.connected": { accountId: string; phoneNumber: string | null };
|
||||||
"session.disconnected": { accountId: string };
|
"session.disconnected": { accountId: string };
|
||||||
"session.timeout": { accountId: string };
|
"session.timeout": { accountId: string };
|
||||||
|
"session.duplicate": {
|
||||||
|
accountId: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
existingLabel: string;
|
||||||
|
};
|
||||||
"groups.synced": { accountId: string; count: number };
|
"groups.synced": { accountId: string; count: number };
|
||||||
"reminder.fired": {
|
"reminder.fired": {
|
||||||
reminderId: string;
|
reminderId: string;
|
||||||
|
|||||||
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 =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
|
// Like account.unpair, but the bot also calls socket.logout() so
|
||||||
|
// WhatsApp drops this device from the operator's linked-devices
|
||||||
|
// list before the row is deleted.
|
||||||
|
| { type: "account.delete"; accountId: string }
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { db } from "./db";
|
import { getCurrentUser } from "./auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the single seeded operator row. Since the app has no auth,
|
* Compatibility shim. The app used to seed a single operator and
|
||||||
* every action is attributed to this operator.
|
* 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() {
|
export async function getSeededOperator() {
|
||||||
const op = await db.query.operators.findFirst({
|
const u = await getCurrentUser();
|
||||||
orderBy: (o, { asc }) => [asc(o.createdAt)],
|
if (!u) {
|
||||||
});
|
throw new Error("Not authenticated");
|
||||||
if (!op) {
|
|
||||||
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
|
|
||||||
}
|
}
|
||||||
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({
|
const accounts = await db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
});
|
});
|
||||||
// All reminder rows so the dashboard can show active/total in one query.
|
// Reminders scoped to this operator's accounts. The previous
|
||||||
// Status enum today is active / ended (paused will join in a later phase).
|
// findMany() with no filter leaked global counts across users — a
|
||||||
const allReminders = await db.query.reminders.findMany();
|
// 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
|
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
||||||
// ownership filter widens to: either the reminder still exists and the
|
// ownership filter widens to: either the reminder still exists and the
|
||||||
// operator owns its account, OR the reminder is gone but the run row
|
// operator owns its account, OR the reminder is gone but the run row
|
||||||
@ -54,9 +63,12 @@ export async function listAccounts(operatorId: string) {
|
|||||||
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||||
// by status produced phantom "I created an account but it's gone"
|
// by status produced phantom "I created an account but it's gone"
|
||||||
// bug reports.
|
// bug reports.
|
||||||
|
// Earliest-added on top, newest at the bottom. Stable across renames
|
||||||
|
// (a label edit shouldn't reorder the list and confuse muscle memory)
|
||||||
|
// and matches how other admin tools order accounts that grow over time.
|
||||||
return db.query.whatsappAccounts.findMany({
|
return db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
orderBy: (a, { asc }) => [asc(a.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);
|
const account = await getAccount(operatorId, accountId);
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const trimmed = (q ?? "").trim();
|
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
|
const rows = trimmed
|
||||||
? await db.execute(sql`
|
? await db.execute(sql`
|
||||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
FROM whatsapp_groups
|
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
|
ORDER BY similarity(name, ${trimmed}) DESC
|
||||||
LIMIT 50
|
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
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
FROM whatsapp_groups
|
FROM whatsapp_groups
|
||||||
WHERE account_id = ${accountId}
|
WHERE account_id = ${accountId}
|
||||||
|
AND is_archived = false
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`);
|
`);
|
||||||
@ -187,11 +208,13 @@ export async function listActivityRuns(
|
|||||||
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||||
// has been deleted but history was preserved) in the list.
|
// has been deleted but history was preserved) in the list.
|
||||||
// The `archived` flag flips the visibility filter:
|
// The `archived` flag flips the visibility filter:
|
||||||
// false (default) — only non-archived rows
|
// false (default) — non-archived, non-skipped rows (skipped runs
|
||||||
// true — only archived rows (for the Archived tab)
|
// 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
|
const archivedClause = opts.archived
|
||||||
? sql`rr.archived_at IS NOT NULL`
|
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
|
||||||
: sql`rr.archived_at IS NULL`;
|
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
|
||||||
const rows = await db.execute(sql`
|
const rows = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
rr.id,
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
const PUBLIC_PATHS = new Set<string>([
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/api/health",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/robots.txt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isPublic(path: string): boolean {
|
||||||
|
if (PUBLIC_PATHS.has(path)) return true;
|
||||||
|
if (path.startsWith("/icon-")) return true;
|
||||||
|
if (path.startsWith("/_next/")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
|
if (isPublic(path)) return NextResponse.next();
|
||||||
|
|
||||||
// Block all /api/* except a small set of read-only endpoints.
|
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
const secret = process.env.AUTH_SECRET;
|
||||||
const allowed =
|
const ok =
|
||||||
path === "/api/events" ||
|
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
||||||
path === "/api/health" ||
|
if (ok) return NextResponse.next();
|
||||||
path.startsWith("/api/qr/");
|
|
||||||
if (path.startsWith("/api/") && !allowed) {
|
if (path.startsWith("/api/")) {
|
||||||
return new NextResponse("Not Found", { status: 404 });
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
return NextResponse.next();
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
||||||
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
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}
|
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||||
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
||||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||||
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
|
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
|
||||||
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
@ -36,6 +36,8 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
|
|
||||||
|
|||||||
@ -59,5 +59,7 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||||
depends_on:
|
depends_on:
|
||||||
- tools
|
- tools
|
||||||
|
|||||||
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/apps/bot /app/apps/bot
|
||||||
COPY --from=build /app/packages/db /app/packages/db
|
COPY --from=build /app/packages/db /app/packages/db
|
||||||
COPY --from=build /app/packages/shared /app/packages/shared
|
COPY --from=build /app/packages/shared /app/packages/shared
|
||||||
|
# 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
|
EXPOSE 8081
|
||||||
CMD ["node", "apps/bot/dist/index.js"]
|
CMD ["node", "apps/bot/dist/index.js"]
|
||||||
|
|||||||
@ -18,7 +18,20 @@ COPY tsconfig.base.json turbo.json ./
|
|||||||
COPY apps/web apps/web
|
COPY apps/web apps/web
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
COPY packages/shared packages/shared
|
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/db build && \
|
||||||
pnpm --filter @cmbot/web 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/standalone ./
|
||||||
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
|
# 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
|
EXPOSE 3000
|
||||||
CMD ["node", "apps/web/server.js"]
|
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_HEALTH_PORT=8081
|
||||||
BOT_LOG_LEVEL=info
|
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 (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
|
SEED_OPERATOR_NAME=Operator
|
||||||
|
|
||||||
# === Web ===
|
# === Web / Auth ===
|
||||||
# Port the Next.js container exposes on the host. Production deployment
|
# 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
|
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=
|
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,
|
"when": 1778464000000,
|
||||||
"tag": "0009_rename_ended_to_inactive",
|
"tag": "0009_rename_ended_to_inactive",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778464001000,
|
||||||
|
"tag": "0010_fancy_wolf_cub",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778464002000,
|
||||||
|
"tag": "0011_premium_grandmaster",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778464003000,
|
||||||
|
"tag": "0012_lucky_masked_marvel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778464004000,
|
||||||
|
"tag": "0013_tricky_yellowjacket",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778464005000,
|
||||||
|
"tag": "0014_lame_puck",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -13,6 +13,10 @@
|
|||||||
"./schema": {
|
"./schema": {
|
||||||
"types": "./dist/schema.d.ts",
|
"types": "./dist/schema.d.ts",
|
||||||
"default": "./dist/schema.js"
|
"default": "./dist/schema.js"
|
||||||
|
},
|
||||||
|
"./journal-check": {
|
||||||
|
"types": "./dist/journal-check.d.ts",
|
||||||
|
"default": "./dist/journal-check.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -26,10 +30,12 @@
|
|||||||
"seed": "tsx src/seed.ts"
|
"seed": "tsx src/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
"pg": "^8.13.0"
|
"pg": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"drizzle-kit": "^0.28.0",
|
"drizzle-kit": "^0.28.0",
|
||||||
|
|||||||
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 { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { createClient } from "./index.js";
|
import { createClient } from "./index.js";
|
||||||
|
import {
|
||||||
|
assertJournalMonotonic,
|
||||||
|
formatJournalViolations,
|
||||||
|
type JournalEntry,
|
||||||
|
} from "./journal-check.js";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
@ -7,6 +15,27 @@ if (!databaseUrl) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pre-flight: refuse to run if the journal is non-monotonic. -----------
|
||||||
|
// Drizzle silently skips entries whose `when` is older than the previous
|
||||||
|
// entry's `when`. We've hit this twice now (0010/0011, then 0012/0013),
|
||||||
|
// each time the symptom was "Migrations applied." with no schema change
|
||||||
|
// and a 500 in production for the missing column. Catch it before we
|
||||||
|
// hand the journal to drizzle.
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const journalPath = join(__dirname, "..", "migrations", "meta", "_journal.json");
|
||||||
|
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
||||||
|
entries: JournalEntry[];
|
||||||
|
};
|
||||||
|
const check = assertJournalMonotonic(journal.entries);
|
||||||
|
if (!check.ok) {
|
||||||
|
console.error(formatJournalViolations(check));
|
||||||
|
console.error(
|
||||||
|
"\nRefusing to run drizzle migrate. Bump the offending `when` values in\n" +
|
||||||
|
"_journal.json so they're strictly increasing in the same order as `idx`.",
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
const { db, pool } = createClient(databaseUrl);
|
const { db, pool } = createClient(databaseUrl);
|
||||||
console.log("Applying migrations...");
|
console.log("Applying migrations...");
|
||||||
await migrate(db, { migrationsFolder: "./migrations" });
|
await migrate(db, { migrationsFolder: "./migrations" });
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
uuid,
|
uuid,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
index,
|
||||||
inet,
|
inet,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
@ -16,14 +18,25 @@ export const operators = pgTable(
|
|||||||
"operators",
|
"operators",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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(),
|
displayName: text("display_name").notNull(),
|
||||||
|
// Reserved for future contact / recovery flows. Optional + nullable
|
||||||
|
// so today's operators don't have to backfill anything; admins can
|
||||||
|
// populate it from the Users page when we wire that up.
|
||||||
|
email: text("email"),
|
||||||
role: text("role").notNull().default("admin"),
|
role: text("role").notNull().default("admin"),
|
||||||
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
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(
|
export const whatsappGroups = pgTable(
|
||||||
"whatsapp_groups",
|
"whatsapp_groups",
|
||||||
{
|
{
|
||||||
@ -58,6 +81,16 @@ export const whatsappGroups = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid),
|
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
|
// Delivery window (operator timezone). End hour is enforced at runtime
|
||||||
// by fire-reminder when window enforcement lands; start hour is documented
|
// by fire-reminder when window enforcement lands; start hour is documented
|
||||||
// here but not gated in v1.
|
// here but not gated in v1.
|
||||||
|
// 24 is the "no deadline" sentinel — it's the off-by-default state so a
|
||||||
|
// reminder created without the operator explicitly opting into "Pause
|
||||||
|
// sending by" stays unbounded.
|
||||||
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
|
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
|
||||||
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18),
|
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reminderTargets = pgTable(
|
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";
|
import { createClient, operators } from "./index.js";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
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";
|
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
|
||||||
|
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error("DATABASE_URL not set");
|
console.error("DATABASE_URL not set");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!operatorTelegramId || operatorTelegramId === "0") {
|
|
||||||
console.error("SEED_OPERATOR_TELEGRAM_ID not set");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { db, pool } = createClient(databaseUrl);
|
const { db, pool } = createClient(databaseUrl);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(operators)
|
.insert(operators)
|
||||||
.values({
|
.values({
|
||||||
telegramUserId: Number(operatorTelegramId),
|
username,
|
||||||
displayName: operatorName,
|
displayName: operatorName,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
defaultTimezone: "Asia/Kuala_Lumpur",
|
defaultTimezone: "Asia/Kuala_Lumpur",
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.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();
|
await pool.end();
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -93,6 +93,9 @@ importers:
|
|||||||
'@types/luxon':
|
'@types/luxon':
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.7.1
|
version: 3.7.1
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -166,6 +169,9 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
|
'@types/bcryptjs':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.7.0
|
specifier: ^22.7.0
|
||||||
version: 22.19.18
|
version: 22.19.18
|
||||||
@ -202,6 +208,9 @@ importers:
|
|||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.36.0
|
specifier: ^0.36.0
|
||||||
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
|
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
|
||||||
@ -209,6 +218,9 @@ importers:
|
|||||||
specifier: ^8.13.0
|
specifier: ^8.13.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/bcryptjs':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.7.0
|
specifier: ^22.7.0
|
||||||
version: 22.19.18
|
version: 22.19.18
|
||||||
@ -2370,6 +2382,10 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/bcryptjs@3.0.0':
|
||||||
|
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
|
||||||
|
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@ -2559,6 +2575,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcryptjs@3.0.3:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -6461,6 +6481,10 @@ snapshots:
|
|||||||
'@turbo/windows-arm64@2.9.12':
|
'@turbo/windows-arm64@2.9.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/bcryptjs@3.0.0':
|
||||||
|
dependencies:
|
||||||
|
bcryptjs: 3.0.3
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@ -6676,6 +6700,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.28: {}
|
baseline-browser-mapping@2.10.28: {}
|
||||||
|
|
||||||
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
|
|||||||
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