Compare commits
No commits in common. "master" and "v1.1.0" have entirely different histories.
@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
|
|||||||
MEDIA_DIR=/data/media
|
MEDIA_DIR=/data/media
|
||||||
BOT_HEALTH_PORT=8081
|
BOT_HEALTH_PORT=8081
|
||||||
BOT_LOG_LEVEL=debug
|
BOT_LOG_LEVEL=debug
|
||||||
SEED_OPERATOR_USERNAME=admin
|
SEED_OPERATOR_TELEGRAM_ID=818380985
|
||||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||||
WEB_PORT=9000
|
WEB_PORT=9000
|
||||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,13 +18,6 @@ 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,36 +6,24 @@ the run history all from a phone home-screen icon.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the
|
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
||||||
primary control surface; the Telegram bot has been removed.
|
the 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. Login lives outside the shell on a bare-header surface.
|
sidebar.
|
||||||
- **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. Recurrence picker covers Daily / Weekly / Monthly / Yearly
|
field. Active recurrence picker covers Daily / Weekly / Monthly /
|
||||||
with multi-rule support and per-rule fire-time pickers; the rendered
|
Yearly with multi-rule support and per-rule fire-time pickers; the
|
||||||
description reads as plain English ("Every week on Mon, Wed, Fri at
|
rendered description reads as plain English ("Every week on Mon,
|
||||||
09:00") not raw cron. Optional "Pause sending by" deadline that
|
Wed, Fri at 09:00") not raw cron.
|
||||||
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.
|
||||||
@ -45,29 +33,19 @@ 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. Click vs drag is disambiguated by a 6-px tap threshold so a
|
style.
|
||||||
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 /
|
||||||
Paused / Failed / Archived). Partial runs surface under both Paused
|
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
|
||||||
and Failed; Skipped runs collapse into Archived. Hard-delete and
|
run to keep the main list readable; restore later. Hard-delete
|
||||||
archive both available; run history survives a reminder deletion.
|
always 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. Logout-on-delete cleans the operator's
|
container restarts.
|
||||||
linked-devices list on the WhatsApp side too.
|
- **All actions audited.** Reminder run history queryable from the
|
||||||
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
|
UI; per-run target results (sent / failed / skipped) preserved
|
||||||
click Save or microsecond-spaced enqueue doesn't fire a reminder
|
even when the underlying group is removed.
|
||||||
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: **482 web + 88 bot = 570** passing.
|
Test count: **249 web + 31 shared + 26 bot = 306** passing.
|
||||||
|
|
||||||
## Host requirements
|
## Host requirements
|
||||||
|
|
||||||
@ -101,28 +79,24 @@ 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 # writes AUTH_SECRET to .env.development
|
scripts/gen_auth_secret.sh --write
|
||||||
|
|
||||||
# 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 the bootstrap operator row
|
# 3. Apply migrations and seed your 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. Set the bootstrap admin password (NO password is set by seed)
|
# 4. Open the web app
|
||||||
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
|
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
||||||
# Public: https://wabot.04080616.xyz
|
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
||||||
```
|
```
|
||||||
|
|
||||||
Inside the app: `/settings/users` → Add user → invite teammates with
|
Pair an account: `/accounts` → "New Account" → enter a label →
|
||||||
`user` role; promote / demote / reset password / delete from the same
|
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
||||||
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.
|
||||||
@ -130,22 +104,10 @@ 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
|
||||||
@ -156,14 +118,11 @@ The earlier wizard-only checklist still lives at
|
|||||||
- `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/runbook.md` — manual end-to-end smoke checklist
|
- `docs/superpowers/specs/` — design specs and manual test runbooks
|
||||||
- `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
|
||||||
|
|
||||||
@ -175,39 +134,17 @@ 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.
|
||||||
- **Search-as-you-type in the wizard's groups picker** — at 3 000+
|
- **Auth** (passkeys / email-password) — bring back if URL exposure
|
||||||
groups per account the picker still loads the alphabetical
|
becomes a concern. Today the app trusts whatever's in front of the
|
||||||
top-200; operators with >200 groups need to use the list page's
|
reverse proxy.
|
||||||
search to find anything past 'L'.
|
- **Multi-operator** — schema supports `operator_id` on every row,
|
||||||
- **Self-service password reset** (email link, etc.) — out of scope
|
but the seed runs as a single operator and there's no /signup or
|
||||||
for v1; admins use the Users page.
|
invite flow yet.
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Notification } from "pg";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { handleStartPairing } from "./pair-handler.js";
|
import { handleStartPairing } from "./pair-handler.js";
|
||||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
import { handleUnpair } from "./unpair-handler.js";
|
||||||
import { handleSyncGroups } from "./sync-groups-handler.js";
|
import { handleSyncGroups } from "./sync-groups-handler.js";
|
||||||
import { handleSendTest } from "./send-test-handler.js";
|
import { handleSendTest } from "./send-test-handler.js";
|
||||||
import {
|
import {
|
||||||
@ -14,11 +14,6 @@ import {
|
|||||||
export type BotCommand =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
// Like unpair, but tells WhatsApp to drop this device from the
|
|
||||||
// user's linked-devices list first via socket.logout(). The web
|
|
||||||
// action calls this immediately before deleting the row so the
|
|
||||||
// operator's phone doesn't keep showing a phantom linked device.
|
|
||||||
| { type: "account.delete"; accountId: string }
|
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||||
@ -79,9 +74,6 @@ export function registerDefaultHandlers(): void {
|
|||||||
registerHandler("account.unpair", async (cmd) => {
|
registerHandler("account.unpair", async (cmd) => {
|
||||||
await handleUnpair(cmd.accountId);
|
await handleUnpair(cmd.accountId);
|
||||||
});
|
});
|
||||||
registerHandler("account.delete", async (cmd) => {
|
|
||||||
await handleDelete(cmd.accountId);
|
|
||||||
});
|
|
||||||
registerHandler("account.sync_groups", async (cmd) => {
|
registerHandler("account.sync_groups", async (cmd) => {
|
||||||
await handleSyncGroups(cmd.accountId);
|
await handleSyncGroups(cmd.accountId);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,16 +10,6 @@ export type WebEvent =
|
|||||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||||
| { type: "session.disconnected"; accountId: string }
|
| { type: "session.disconnected"; accountId: string }
|
||||||
| { type: "session.timeout"; accountId: string }
|
| { type: "session.timeout"; accountId: string }
|
||||||
// Operator scanned the QR with a phone that's already linked to another
|
|
||||||
// account row. We park the new pairing instead of letting two account
|
|
||||||
// rows fight over the same WhatsApp device. existingLabel surfaces in
|
|
||||||
// the UI so the operator knows which account already owns the phone.
|
|
||||||
| {
|
|
||||||
type: "session.duplicate";
|
|
||||||
accountId: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
existingLabel: string;
|
|
||||||
}
|
|
||||||
| { type: "groups.synced"; accountId: string; count: number }
|
| { type: "groups.synced"; accountId: string; count: number }
|
||||||
| {
|
| {
|
||||||
type: "reminder.fired";
|
type: "reminder.fired";
|
||||||
|
|||||||
@ -10,23 +10,11 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
|
|||||||
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { pgNotifyWeb } from "./notify.js";
|
import { pgNotifyWeb } from "./notify.js";
|
||||||
import {
|
|
||||||
decidePairListenerOnClose,
|
|
||||||
findDuplicateExistingAccount,
|
|
||||||
nextWarmingUpAfterEvent,
|
|
||||||
} from "./pair-state.js";
|
|
||||||
|
|
||||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
const offByAccount = new Map<string, () => void>();
|
const offByAccount = new Map<string, () => void>();
|
||||||
const lastQrPayload = new Map<string, string>();
|
const lastQrPayload = new Map<string, string>();
|
||||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
// "Warming" set: while present, the just-attached listener will ignore
|
|
||||||
// close events. Cleared the moment a qr/open arrives. This prevents the
|
|
||||||
// old session's close (broadcast asynchronously by sessionManager after
|
|
||||||
// our await sessionManager.stop() returns) from being mis-read as the
|
|
||||||
// NEW session timing out — which manifested as: get QR → go back →
|
|
||||||
// click Pair again → instantly see "Pairing timed out".
|
|
||||||
const pairingWarmingUp = new Set<string>();
|
|
||||||
|
|
||||||
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
@ -46,7 +34,6 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
|||||||
pairTimeouts.delete(accountId);
|
pairTimeouts.delete(accountId);
|
||||||
}
|
}
|
||||||
lastQrPayload.delete(accountId);
|
lastQrPayload.delete(accountId);
|
||||||
pairingWarmingUp.delete(accountId);
|
|
||||||
if (sessionManager.hasSession(accountId)) {
|
if (sessionManager.hasSession(accountId)) {
|
||||||
await sessionManager.stop(accountId);
|
await sessionManager.stop(accountId);
|
||||||
}
|
}
|
||||||
@ -93,17 +80,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
.set({ lastQrPng: null })
|
.set({ lastQrPng: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
|
|
||||||
// Mark the new attempt as warming up. Cleared by the first qr/open we
|
|
||||||
// observe; while set, any close event is treated as the leaked tail of
|
|
||||||
// the previous session being torn down (see comment near
|
|
||||||
// `pairingWarmingUp` declaration).
|
|
||||||
pairingWarmingUp.add(accountId);
|
|
||||||
|
|
||||||
const off = sessionManager.on(async (id, _state, event) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
if (event.type === "qr") {
|
if (event.type === "qr") {
|
||||||
pairingWarmingUp.delete(id);
|
|
||||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||||
// burst. Different strings (a fresh QR) always pass through, so
|
// burst. Different strings (a fresh QR) always pass through, so
|
||||||
// the user gets a new QR as soon as Baileys generates one.
|
// the user gets a new QR as soon as Baileys generates one.
|
||||||
@ -122,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
});
|
});
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
pairingWarmingUp.delete(id);
|
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@ -130,53 +109,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
lastQrPayload.delete(id);
|
lastQrPayload.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
|
|
||||||
// Duplicate-pair guard. Operator scanned the QR with a phone
|
|
||||||
// that's already linked to another account row. Letting both
|
|
||||||
// rows claim the same WhatsApp device confuses Baileys and
|
|
||||||
// turns sends into a coin flip — abandon this pairing and
|
|
||||||
// surface a clear message to the UI.
|
|
||||||
const siblings = await db.query.whatsappAccounts.findMany({
|
|
||||||
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
|
|
||||||
columns: { id: true, phoneNumber: true, label: true },
|
|
||||||
});
|
|
||||||
const dup = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: id,
|
|
||||||
currentPhoneNumber: event.phoneNumber,
|
|
||||||
siblings,
|
|
||||||
});
|
|
||||||
if (dup) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
accountId: id,
|
|
||||||
phoneNumber: event.phoneNumber,
|
|
||||||
existingAccountId: dup.existingAccountId,
|
|
||||||
existingLabel: dup.existingLabel,
|
|
||||||
},
|
|
||||||
"pair: duplicate phone — abandoning new pairing",
|
|
||||||
);
|
|
||||||
// Stop the duplicate session, scrub the partial auth blob,
|
|
||||||
// and reset the row's status. We DO NOT logout() here — the
|
|
||||||
// original account's session remains valid and the operator
|
|
||||||
// hasn't actually added a new linked device on the phone yet
|
|
||||||
// (it'd just be the freshly-completed scan, which Baileys
|
|
||||||
// hasn't yet committed to the WhatsApp side).
|
|
||||||
await sessionManager.stop(id, { intentional: true });
|
|
||||||
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
|
|
||||||
await db
|
|
||||||
.update(whatsappAccounts)
|
|
||||||
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
|
|
||||||
.where(eq(whatsappAccounts.id, id));
|
|
||||||
await pgNotifyWeb({
|
|
||||||
type: "session.duplicate",
|
|
||||||
accountId: id,
|
|
||||||
phoneNumber: event.phoneNumber!,
|
|
||||||
existingLabel: dup.existingLabel,
|
|
||||||
});
|
|
||||||
off();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = sessionManager.getSession(id);
|
const session = sessionManager.getSession(id);
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
if (session) {
|
if (session) {
|
||||||
@ -202,42 +134,27 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
count: synced,
|
count: synced,
|
||||||
});
|
});
|
||||||
off();
|
off();
|
||||||
|
} else if (event.type === "close" && event.restartRequired) {
|
||||||
|
// After the user scans, WhatsApp tells Baileys to "restart"
|
||||||
|
// the connection. The socket closes with status 515 and the
|
||||||
|
// session-manager will reopen it with the new credentials —
|
||||||
|
// the next `open` event is what completes the pairing.
|
||||||
|
// This is NOT a failure: keep the listener attached so we see
|
||||||
|
// that subsequent `open` event, and don't surface a timeout
|
||||||
|
// to the UI. The DB row stays in `pending` until `open`.
|
||||||
|
logger.info(
|
||||||
|
{ accountId: id },
|
||||||
|
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
||||||
|
);
|
||||||
|
// The session-manager handles the actual reconnect; nothing to
|
||||||
|
// do here other than NOT tear our listener / DB state down.
|
||||||
} else if (event.type === "close") {
|
} else if (event.type === "close") {
|
||||||
const decision = decidePairListenerOnClose({
|
// During the pairing window, any other close means the QR window
|
||||||
warmingUp: pairingWarmingUp.has(id),
|
// ended without a successful link — Baileys' default is to
|
||||||
restartRequired: event.restartRequired,
|
// close after exhausting QR refs (~2.5 min). Surface this to
|
||||||
});
|
// the UI so the user gets a "pairing timed out" screen, and
|
||||||
if (decision === "ignore-leaked-close") {
|
// park the row in a stable state so it shows up cleanly on
|
||||||
logger.info(
|
// the accounts list with a "Re-pair" affordance.
|
||||||
{ accountId: id },
|
|
||||||
"pair: ignoring close from previous attempt while warming up",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (decision === "post-pair-restart") {
|
|
||||||
// After the user scans, WhatsApp tells Baileys to "restart"
|
|
||||||
// the connection. The socket closes with status 515 and the
|
|
||||||
// session-manager will reopen it with the new credentials —
|
|
||||||
// the next `open` event finishes the pairing. Keep the
|
|
||||||
// listener attached and don't surface a timeout to the UI.
|
|
||||||
//
|
|
||||||
// Re-arm the warming-up flag: the session-manager schedules a
|
|
||||||
// cleanup `stop().then(start())` to kick off the reconnect.
|
|
||||||
// That stop emits another close event that lands on this
|
|
||||||
// listener BEFORE the new open arrives — without warming-up,
|
|
||||||
// we'd treat it as a timeout and detach right when the user
|
|
||||||
// actually paired successfully. Cleared again on the next
|
|
||||||
// qr / open from the freshly-reopened session.
|
|
||||||
pairingWarmingUp.add(id);
|
|
||||||
logger.info(
|
|
||||||
{ accountId: id },
|
|
||||||
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// decision === "treat-as-timeout": ephemeral close on a live
|
|
||||||
// attempt. Park the row as `unpaired` and push session.timeout
|
|
||||||
// so the operator sees the "Re-pair" affordance.
|
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
|
|||||||
@ -2,9 +2,6 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
decideOnPairClose,
|
decideOnPairClose,
|
||||||
decideOnPairTimeout,
|
decideOnPairTimeout,
|
||||||
decidePairListenerOnClose,
|
|
||||||
findDuplicateExistingAccount,
|
|
||||||
nextWarmingUpAfterEvent,
|
|
||||||
shouldAutoReconnect,
|
shouldAutoReconnect,
|
||||||
} from "./pair-state.js";
|
} from "./pair-state.js";
|
||||||
|
|
||||||
@ -85,225 +82,3 @@ describe("shouldAutoReconnect", () => {
|
|||||||
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
|
|
||||||
it("ignores a close while warming up — even if also restartRequired", () => {
|
|
||||||
// The exact bug: stop() was awaited, listener attached, then the OLD
|
|
||||||
// session's close arrives and races our new listener. Warming-up
|
|
||||||
// wins over every other branch so the UI never sees a spurious
|
|
||||||
// session.timeout before the new QR is rendered.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
|
|
||||||
// Refs exhausted, network blip, etc. — operator gets the
|
|
||||||
// "Pairing timed out" screen and a Re-pair affordance.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
|
|
||||||
).toBe("treat-as-timeout");
|
|
||||||
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
|
|
||||||
// Status 515 close: the session-manager will reconnect and the next
|
|
||||||
// `open` finishes the pair. We must NOT push session.timeout here.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
|
|
||||||
).toBe("post-pair-restart");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
|
|
||||||
// Defense-in-depth: if Baileys' restart-required close from the OLD
|
|
||||||
// session somehow leaks through, treating it as a real 515 would
|
|
||||||
// KEEP the listener attached forever (no reconnect comes from a
|
|
||||||
// session we just stopped). Ignore it entirely until a fresh qr/open.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
|
|
||||||
it("first qr from the live session clears warming-up", () => {
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("first open from the live session clears warming-up", () => {
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
|
|
||||||
// The regression: after the user scans, Baileys closes with status
|
|
||||||
// 515 and the session-manager schedules a stop().then(start())
|
|
||||||
// reconnect. That cleanup-stop emits a SECOND close that arrives
|
|
||||||
// before the new socket reopens. If warming-up isn't re-armed
|
|
||||||
// between the two closes, the second one resolves to
|
|
||||||
// 'treat-as-timeout' and detaches the listener right at the
|
|
||||||
// moment the user actually paired successfully — UI never gets
|
|
||||||
// session.connected.
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("plain close leaves warming-up unchanged", () => {
|
|
||||||
// The pair-handler decides what to DO with a non-restart close
|
|
||||||
// separately (decidePairListenerOnClose). The warming-up flag
|
|
||||||
// doesn't change as a side effect — the listener either detaches
|
|
||||||
// (treat-as-timeout) or already returned (ignore-leaked-close).
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
|
|
||||||
// Full lifecycle the helper has to thread correctly so the user
|
|
||||||
// sees 'Account connected!' instead of 'Pairing timed out'.
|
|
||||||
let warming = true; // freshly attached listener after a re-pair
|
|
||||||
|
|
||||||
// First QR arrives — clears the leak-protection flag.
|
|
||||||
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
|
|
||||||
expect(warming).toBe(false);
|
|
||||||
|
|
||||||
// User scans → Baileys closes with restartRequired=true.
|
|
||||||
// Re-arms because session-manager will run another stop+start.
|
|
||||||
warming = nextWarmingUpAfterEvent({
|
|
||||||
warmingUp: warming,
|
|
||||||
event: "close",
|
|
||||||
restartRequired: true,
|
|
||||||
});
|
|
||||||
expect(warming).toBe(true);
|
|
||||||
|
|
||||||
// The cleanup-stop's second close arrives. The CALLER decides via
|
|
||||||
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
// Flag stays armed because a plain close doesn't change it.
|
|
||||||
warming = nextWarmingUpAfterEvent({
|
|
||||||
warmingUp: warming,
|
|
||||||
event: "close",
|
|
||||||
restartRequired: false,
|
|
||||||
});
|
|
||||||
expect(warming).toBe(true);
|
|
||||||
|
|
||||||
// Fresh socket opens with the new credentials → success.
|
|
||||||
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
|
|
||||||
expect(warming).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
|
|
||||||
const sibling = (id: string, phone: string | null, label: string) => ({
|
|
||||||
id,
|
|
||||||
phoneNumber: phone,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a sibling that already holds this phone number", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("existing", "60123456789", "Yiekheng-my"),
|
|
||||||
sibling("other", "60987654321", "WaBot Test"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r).toEqual({
|
|
||||||
existingAccountId: "existing",
|
|
||||||
existingLabel: "Yiekheng-my",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the phone is unique", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("other", "60987654321", "WaBot"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
|
|
||||||
// After session-manager.handleEvent runs first it has already
|
|
||||||
// written phone_number on the current row. The check must skip
|
|
||||||
// that row, otherwise EVERY successful pair would match itself
|
|
||||||
// and look like a duplicate.
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "self",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [sibling("self", "60123456789", "Self")],
|
|
||||||
});
|
|
||||||
expect(r).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
|
|
||||||
const siblings = [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("a", null, "Old A"),
|
|
||||||
sibling("b", "", "Old B"),
|
|
||||||
sibling("c", " ", "Old C"),
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: null,
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "",
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: " ",
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalises whitespace on both sides before comparing", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: " 60123456789 ",
|
|
||||||
siblings: [sibling("existing", "60123456789", "Existing")],
|
|
||||||
});
|
|
||||||
expect(r?.existingAccountId).toBe("existing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
|
|
||||||
// Defensive: this state shouldn't exist in production but the helper
|
|
||||||
// should at least be deterministic so the message is consistent.
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("first", "60123456789", "First"),
|
|
||||||
sibling("second", "60123456789", "Second"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r?.existingAccountId).toBe("first");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -80,106 +80,3 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
|
|||||||
if (current !== "pending") return null;
|
if (current !== "pending") return null;
|
||||||
return { next: "unpaired", clearQrPng: true };
|
return { next: "unpaired", clearQrPng: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide how the pair-handler should react to a `close` event delivered
|
|
||||||
* to its listener. Three outcomes:
|
|
||||||
*
|
|
||||||
* - "ignore-leaked-close": the new attempt is still warming up and
|
|
||||||
* we're seeing the OLD session's tail close. Do nothing — don't
|
|
||||||
* emit timeout to the UI, don't touch the DB row.
|
|
||||||
* - "post-pair-restart": status-515 close from a successful scan.
|
|
||||||
* The session-manager will reconnect; we keep the listener alive
|
|
||||||
* and wait for the subsequent `open` event.
|
|
||||||
* - "treat-as-timeout": a real ephemeral close on a live attempt
|
|
||||||
* (refs exhausted, etc.). Park the row as `unpaired` and push
|
|
||||||
* `session.timeout` to the UI.
|
|
||||||
*
|
|
||||||
* Captures the regression where, after the user pulled up a QR and
|
|
||||||
* navigated back, clicking Pair again would instantly flash "Pairing
|
|
||||||
* timed out" because the await on stop() returned before
|
|
||||||
* sessionManager.handleEvent finished broadcasting the old session's
|
|
||||||
* close — and the new listener was already attached.
|
|
||||||
*/
|
|
||||||
export type PairListenerCloseDecision =
|
|
||||||
| "ignore-leaked-close"
|
|
||||||
| "post-pair-restart"
|
|
||||||
| "treat-as-timeout";
|
|
||||||
|
|
||||||
export function decidePairListenerOnClose(input: {
|
|
||||||
warmingUp: boolean;
|
|
||||||
restartRequired?: boolean;
|
|
||||||
}): PairListenerCloseDecision {
|
|
||||||
if (input.warmingUp) return "ignore-leaked-close";
|
|
||||||
if (input.restartRequired) return "post-pair-restart";
|
|
||||||
return "treat-as-timeout";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step the pair-listener's warming-up flag forward through one Baileys
|
|
||||||
* event. Captures three rules in one place so they're test-locked:
|
|
||||||
*
|
|
||||||
* - First `qr` / `open` from the live session clears warming-up
|
|
||||||
* (we've seen real session activity, future closes are real).
|
|
||||||
* - `close + restartRequired` (post-pair-success / status 515)
|
|
||||||
* RE-ARMS warming-up. The session-manager will schedule a
|
|
||||||
* `stop().then(start())` reconnect; that stop emits a second close
|
|
||||||
* before the new socket reopens. Without re-arming, the leaked
|
|
||||||
* close from the cleanup-stop reaches us with warming-up=false and
|
|
||||||
* resolves to `treat-as-timeout` — detaching the listener right at
|
|
||||||
* the moment the user actually paired successfully (regression).
|
|
||||||
* - Any other `close` keeps warming-up unchanged (the listener
|
|
||||||
* either ignored it because we're warming, or processed it as a
|
|
||||||
* real timeout / restart and is leaving the loop anyway).
|
|
||||||
*/
|
|
||||||
export function nextWarmingUpAfterEvent(input: {
|
|
||||||
warmingUp: boolean;
|
|
||||||
event: "qr" | "open" | "close";
|
|
||||||
restartRequired?: boolean;
|
|
||||||
}): boolean {
|
|
||||||
if (input.event === "qr" || input.event === "open") return false;
|
|
||||||
if (input.event === "close" && input.restartRequired) return true;
|
|
||||||
return input.warmingUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a freshly-paired account is a duplicate of an
|
|
||||||
* existing account row owned by the same operator. The operator
|
|
||||||
* cannot legitimately link the same WhatsApp number to two account
|
|
||||||
* rows — Baileys keeps one auth blob per phone and the second row
|
|
||||||
* would just hijack the first's session.
|
|
||||||
*
|
|
||||||
* Inputs:
|
|
||||||
* - `currentAccountId` the row that just received the open event
|
|
||||||
* - `currentPhoneNumber` the JID-derived phone string (or null)
|
|
||||||
* - `siblings` every other operator-owned account row
|
|
||||||
*
|
|
||||||
* Returns `null` if the phone is unique (proceed normally), or a
|
|
||||||
* descriptor with the existing-row's id+label so the caller can park
|
|
||||||
* the duplicate row and surface a clear "already linked" message to
|
|
||||||
* the UI. A null/empty phone never reports a duplicate (we'd be
|
|
||||||
* comparing apples and we'd block legitimate first pairs that
|
|
||||||
* haven't received the WID yet).
|
|
||||||
*/
|
|
||||||
export interface DuplicatePairInput {
|
|
||||||
currentAccountId: string;
|
|
||||||
currentPhoneNumber: string | null | undefined;
|
|
||||||
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
|
|
||||||
}
|
|
||||||
export interface DuplicatePairFinding {
|
|
||||||
existingAccountId: string;
|
|
||||||
existingLabel: string;
|
|
||||||
}
|
|
||||||
export function findDuplicateExistingAccount(
|
|
||||||
input: DuplicatePairInput,
|
|
||||||
): DuplicatePairFinding | null {
|
|
||||||
const phone = (input.currentPhoneNumber ?? "").trim();
|
|
||||||
if (!phone) return null;
|
|
||||||
for (const s of input.siblings) {
|
|
||||||
if (s.id === input.currentAccountId) continue;
|
|
||||||
if ((s.phoneNumber ?? "").trim() === phone) {
|
|
||||||
return { existingAccountId: s.id, existingLabel: s.label };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// Hoisted spies so the vi.mock factories can reach them.
|
|
||||||
const {
|
|
||||||
stopMock,
|
|
||||||
logoutAndStopMock,
|
|
||||||
rmMock,
|
|
||||||
findFirstMock,
|
|
||||||
writeAuditLogMock,
|
|
||||||
pgNotifyWebMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
stopMock: vi.fn(async () => undefined),
|
|
||||||
logoutAndStopMock: vi.fn(async () => undefined),
|
|
||||||
rmMock: vi.fn(async () => undefined),
|
|
||||||
findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })),
|
|
||||||
writeAuditLogMock: vi.fn(async () => undefined),
|
|
||||||
pgNotifyWebMock: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
|
||||||
rm: (...args: unknown[]) => rmMock(...args),
|
|
||||||
}));
|
|
||||||
vi.mock("../db.js", () => ({
|
|
||||||
db: {
|
|
||||||
query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } }));
|
|
||||||
vi.mock("../whatsapp/session-manager.js", () => ({
|
|
||||||
sessionManager: {
|
|
||||||
stop: (...a: unknown[]) => stopMock(...a),
|
|
||||||
logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("../audit.js", () => ({
|
|
||||||
writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("./notify.js", () => ({
|
|
||||||
pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("../logger.js", () => ({
|
|
||||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
stopMock.mockReset();
|
|
||||||
stopMock.mockResolvedValue(undefined);
|
|
||||||
logoutAndStopMock.mockReset();
|
|
||||||
logoutAndStopMock.mockResolvedValue(undefined);
|
|
||||||
rmMock.mockReset();
|
|
||||||
rmMock.mockResolvedValue(undefined);
|
|
||||||
findFirstMock.mockReset();
|
|
||||||
findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" });
|
|
||||||
writeAuditLogMock.mockReset();
|
|
||||||
writeAuditLogMock.mockResolvedValue(undefined);
|
|
||||||
pgNotifyWebMock.mockReset();
|
|
||||||
pgNotifyWebMock.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleUnpair", () => {
|
|
||||||
it("stops the session WITHOUT logout, removes files, audits, notifies", async () => {
|
|
||||||
await handleUnpair("acct-A");
|
|
||||||
// The unpair flow MUST NOT call logoutAndStop — that would tell
|
|
||||||
// WhatsApp to drop the linked device, which the operator might
|
|
||||||
// re-pair shortly after. logoutAndStop is only for permanent
|
|
||||||
// delete.
|
|
||||||
expect(logoutAndStopMock).not.toHaveBeenCalled();
|
|
||||||
expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true });
|
|
||||||
expect(rmMock).toHaveBeenCalled();
|
|
||||||
expect(writeAuditLogMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }),
|
|
||||||
);
|
|
||||||
expect(pgNotifyWebMock).toHaveBeenCalledWith({
|
|
||||||
type: "session.disconnected",
|
|
||||||
accountId: "acct-A",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleDelete (logout-before-teardown)", () => {
|
|
||||||
it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => {
|
|
||||||
await handleDelete("acct-A");
|
|
||||||
expect(logoutAndStopMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A");
|
|
||||||
expect(rmMock).toHaveBeenCalledTimes(1);
|
|
||||||
// Order: logout-and-stop must invoke before rm (otherwise the
|
|
||||||
// socket was torn down on disk before WhatsApp could be told to
|
|
||||||
// drop the linked device).
|
|
||||||
expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
rmMock.mock.invocationCallOrder[0]!,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT call the plain stop() — that's reserved for unpair", async () => {
|
|
||||||
// Sanity guard: a refactor that swaps logoutAndStop for stop()
|
|
||||||
// would silently regress the linked-device cleanup. The test
|
|
||||||
// pins the contract.
|
|
||||||
await handleDelete("acct-A");
|
|
||||||
expect(stopMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes an account.deleted audit log carrying the row's label", async () => {
|
|
||||||
findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" });
|
|
||||||
await handleDelete("acct-X");
|
|
||||||
expect(writeAuditLogMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "account.deleted",
|
|
||||||
operatorId: "op-7",
|
|
||||||
targetId: "acct-X",
|
|
||||||
payload: { label: "Yiekheng-my" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still completes when the audit-log lookup fails (best-effort)", async () => {
|
|
||||||
// The web action runs the cascade DELETE right after; if the row
|
|
||||||
// is gone before this handler reads it, the audit lookup throws.
|
|
||||||
// Delete must not strand on that.
|
|
||||||
findFirstMock.mockRejectedValueOnce(new Error("no such row"));
|
|
||||||
await expect(handleDelete("acct-A")).resolves.toBeUndefined();
|
|
||||||
expect(rmMock).toHaveBeenCalled();
|
|
||||||
expect(pgNotifyWebMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -39,41 +39,3 @@ export async function handleUnpair(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete-account flow on the bot side. Distinct from unpair because
|
|
||||||
* we want WhatsApp to drop this device from the user's linked-devices
|
|
||||||
* list — otherwise the phone keeps showing a phantom entry that has
|
|
||||||
* to be manually removed from WhatsApp's UI.
|
|
||||||
*
|
|
||||||
* Order is important:
|
|
||||||
* 1. socket.logout() over the still-connected socket → WhatsApp
|
|
||||||
* removes the linked device on the server side.
|
|
||||||
* 2. close() the local Baileys session.
|
|
||||||
* 3. rm() the on-disk auth blob so the next pairing starts clean.
|
|
||||||
*
|
|
||||||
* Step 1 is best-effort — if the socket is already torn down or the
|
|
||||||
* RPC fails the delete still proceeds. The web action then deletes
|
|
||||||
* the row (cascade FKs handle groups/reminders/runs).
|
|
||||||
*/
|
|
||||||
export async function handleDelete(accountId: string): Promise<void> {
|
|
||||||
await sessionManager.logoutAndStop(accountId);
|
|
||||||
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
|
|
||||||
try {
|
|
||||||
const row = await db.query.whatsappAccounts.findFirst({
|
|
||||||
where: (a, { eq }) => eq(a.id, accountId),
|
|
||||||
columns: { operatorId: true, label: true },
|
|
||||||
});
|
|
||||||
await writeAuditLog(db, {
|
|
||||||
operatorId: row?.operatorId ?? null,
|
|
||||||
source: "web",
|
|
||||||
action: "account.deleted",
|
|
||||||
targetType: "whatsapp_account",
|
|
||||||
targetId: accountId,
|
|
||||||
payload: { label: row?.label ?? null },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
|
|
||||||
}
|
|
||||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
|
||||||
}
|
|
||||||
|
|||||||
@ -108,51 +108,6 @@ describe("fireReminder", () => {
|
|||||||
expect(accountMutex.run).not.toHaveBeenCalled();
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
|
|
||||||
// Repro: three pg-boss jobs arrive in the same microsecond. All
|
|
||||||
// three pass the OUTER recent-run check (no run exists yet) and
|
|
||||||
// queue up on the per-account mutex. The first acquires, INSERTs
|
|
||||||
// a run, sends. The second acquires AFTER the first finished —
|
|
||||||
// its inner check now sees the just-inserted run and must bail,
|
|
||||||
// otherwise the message would be sent twice (or three times for
|
|
||||||
// the third job). Without the inner check this regression
|
|
||||||
// produced "qwerd msg three times" in production.
|
|
||||||
getReminderMock.mockResolvedValue({
|
|
||||||
id: "r-1",
|
|
||||||
accountId: "acct-A",
|
|
||||||
status: "active",
|
|
||||||
targets: [],
|
|
||||||
messages: [],
|
|
||||||
createdBy: "op-1",
|
|
||||||
scheduleKind: "one_off",
|
|
||||||
rrule: null,
|
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
|
||||||
});
|
|
||||||
// First call (outer check) returns no recent run → mutex acquired.
|
|
||||||
// Second call (inner check inside fireReminderInner) returns a
|
|
||||||
// freshly-inserted run from the concurrent winner, so the INSERT
|
|
||||||
// path bails. We never reach the .insert(reminderRuns) builder so
|
|
||||||
// the test passes by virtue of the inner-check log + early return.
|
|
||||||
findExistingRunMock
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: "run-just-inserted-by-the-other-worker",
|
|
||||||
reminderId: "r-1",
|
|
||||||
firedAt: new Date(),
|
|
||||||
status: "pending",
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireReminder({ reminderId: "r-1" });
|
|
||||||
|
|
||||||
// The mutex DID get acquired (we got past the outer check), but
|
|
||||||
// the inner check should have stopped us before any side effects.
|
|
||||||
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
|
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
|
||||||
// Two pg-boss jobs landing within microseconds for the same
|
// Two pg-boss jobs landing within microseconds for the same
|
||||||
// reminder should NOT both fire. The first creates the run; the
|
// reminder should NOT both fire. The first creates the run; the
|
||||||
|
|||||||
@ -154,32 +154,6 @@ async function fireReminderInner(
|
|||||||
.set({ status: "pending", errorSummary: null })
|
.set({ status: "pending", errorSummary: null })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
} else {
|
} else {
|
||||||
// Re-check the dedupe window now that we're inside the per-account
|
|
||||||
// mutex. The outer check in fireReminder() is a fast-path bail-out
|
|
||||||
// but it's TOCTOU: three concurrent jobs can all read "no recent
|
|
||||||
// run" before any of them inserts, so the message gets sent 2-3
|
|
||||||
// times. Inside the mutex, the queue serialises us — by the time
|
|
||||||
// duplicate #2 reaches this point, duplicate #1 has already
|
|
||||||
// INSERTed and we'll find that row here.
|
|
||||||
const recent = await db.query.reminderRuns.findFirst({
|
|
||||||
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
|
|
||||||
dAnd(
|
|
||||||
dEq(r.reminderId, reminder.id),
|
|
||||||
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
|
|
||||||
),
|
|
||||||
orderBy: (r, { desc }) => [desc(r.firedAt)],
|
|
||||||
});
|
|
||||||
if (recent) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
reminderId: reminder.id,
|
|
||||||
recentRunId: recent.id,
|
|
||||||
recentFiredAt: recent.firedAt,
|
|
||||||
},
|
|
||||||
"fire-reminder: duplicate fire detected inside mutex (a run was just inserted by a concurrent job), skipping",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [run] = await db
|
const [run] = await db
|
||||||
.insert(reminderRuns)
|
.insert(reminderRuns)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// pg-boss + db mocks. Hoisted so the vi.mock factories below can refer
|
|
||||||
// to the spies — see https://vitest.dev/api/vi.html#vi-hoisted.
|
|
||||||
const {
|
|
||||||
bossSendMock,
|
|
||||||
dbExecuteMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"),
|
|
||||||
dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../db.js", () => ({
|
|
||||||
db: { execute: (...a: unknown[]) => dbExecuteMock(...a) },
|
|
||||||
}));
|
|
||||||
vi.mock("../logger.js", () => ({
|
|
||||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// We don't import pg-boss directly — scheduleReminderFire receives a
|
|
||||||
// PgBoss instance as its first arg. Build a minimal stub that exposes
|
|
||||||
// just the .send method (and createQueue / work for registerReminderJobs
|
|
||||||
// if we ever wire it here).
|
|
||||||
const fakeBoss = {
|
|
||||||
send: bossSendMock,
|
|
||||||
} as unknown as Parameters<typeof scheduleReminderFire>[0];
|
|
||||||
|
|
||||||
import { scheduleReminderFire } from "./reminder-jobs.js";
|
|
||||||
|
|
||||||
const REMINDER_ID = "11111111-1111-1111-1111-111111111111";
|
|
||||||
const SINGLETON_KEY = `reminder:${REMINDER_ID}`;
|
|
||||||
const FIRE_AT = new Date("2026-05-10T12:20:00.000Z");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bossSendMock.mockReset();
|
|
||||||
bossSendMock.mockResolvedValue("new-job-id");
|
|
||||||
dbExecuteMock.mockReset();
|
|
||||||
dbExecuteMock.mockResolvedValue({ rows: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => {
|
|
||||||
it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => {
|
|
||||||
// Repro of the dropped-fire bug: the queue was on policy=stately
|
|
||||||
// and a prior schedule had left a 'created' job in pg-boss with
|
|
||||||
// the same singletonKey. The new send returned null and the
|
|
||||||
// user's 8:20 PM fire was silently lost. Under the fix, we MUST
|
|
||||||
// tombstone any prior created jobs FIRST so the new send wins
|
|
||||||
// even under standard policy.
|
|
||||||
dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] });
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
// Order matters: cancel happens before send.
|
|
||||||
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
bossSendMock.mock.invocationCallOrder[0]!,
|
|
||||||
);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => {
|
|
||||||
// The cancel must NOT touch in-flight runs (state='active') nor
|
|
||||||
// historical fires (state='completed'). Otherwise we'd nuke the
|
|
||||||
// run that's currently sending and the user gets phantom 'failed'
|
|
||||||
// rows in the activity feed.
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
|
|
||||||
// Drizzle's sql template returns an SQL object; serialise to inspect.
|
|
||||||
const text = JSON.stringify(sqlStmt);
|
|
||||||
expect(text).toMatch(/state\s*=\s*'?created'?/);
|
|
||||||
expect(text).not.toMatch(/state\s*=\s*'?active'?/);
|
|
||||||
expect(text).not.toMatch(/state\s*=\s*'?completed'?/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => {
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
|
|
||||||
const text = JSON.stringify(sqlStmt);
|
|
||||||
// The reminderId must appear in the WHERE clause's bound params
|
|
||||||
// (drizzle stores them in the serialised payload).
|
|
||||||
expect(text).toContain(REMINDER_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the singleton key through to boss.send for diagnostics", async () => {
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const [, , opts] = bossSendMock.mock.calls[0]!;
|
|
||||||
expect(opts).toMatchObject({
|
|
||||||
singletonKey: SINGLETON_KEY,
|
|
||||||
startAfter: FIRE_AT,
|
|
||||||
retryLimit: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => {
|
|
||||||
// First time scheduling a reminder — no stale rows exist.
|
|
||||||
dbExecuteMock.mockResolvedValueOnce({ rows: [] });
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => {
|
|
||||||
// pg connection blip during cancel must not strand the schedule.
|
|
||||||
// Worst case we end up with two created jobs and the
|
|
||||||
// handler-level recent-run dedupe drops the duplicate fire.
|
|
||||||
dbExecuteMock.mockRejectedValueOnce(new Error("connection reset"));
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns boss.send's null when pg-boss itself rejects the send", async () => {
|
|
||||||
// Defense check: if pg-boss returns null for any reason (queue
|
|
||||||
// missing, future stately-style policy quirks, etc), surface that
|
|
||||||
// up so the caller's logger captures jobId: null.
|
|
||||||
bossSendMock.mockResolvedValueOnce(null);
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,39 +1,21 @@
|
|||||||
import type { PgBoss } from "pg-boss";
|
import type { PgBoss } from "pg-boss";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { db } from "../db.js";
|
|
||||||
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
||||||
|
|
||||||
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
||||||
|
|
||||||
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
||||||
// 'standard' (the default) lets us enqueue a new fire even when an
|
// 'stately' = at most 1 job per (state, singletonKey). Combined with
|
||||||
// older one for the same singletonKey is still 'created'. We need
|
// singletonKey="reminder:<id>" on every send, that means a duplicate
|
||||||
// that for the recurring/edit path: when a reminder is rescheduled,
|
// schedule call (e.g. operator double-clicked Save, or the
|
||||||
// scheduleReminderFire() first cancels the stale 'created' job for
|
// pg_notify('bot.command') consumer fired twice in the same tick)
|
||||||
// this reminder and then sends a new one — under 'stately' the
|
// is folded into the existing 'created' job instead of producing a
|
||||||
// SECOND send returns null (it dedupes against the first across
|
// second run. The default 'standard' policy DOES NOT dedupe by
|
||||||
// states), so a reschedule silently dropped the new fire and the
|
// singletonKey — that's how we ended up firing a reminder twice
|
||||||
// reminder never fired at the new time. Duplicate-fire safety is
|
// when two reminder.fire jobs landed within microseconds.
|
||||||
// covered at the handler level by the inner-mutex recent-run check
|
// https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies
|
||||||
// in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches
|
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" });
|
||||||
// the microsecond-spaced send case 'stately' was supposed to guard.
|
|
||||||
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "standard" });
|
|
||||||
// pg-boss v12's createQueue is idempotent and DOES NOT update the
|
|
||||||
// policy on an existing queue row. Earlier deployments forced
|
|
||||||
// policy='stately' here, which broke reschedules. Force-flip back to
|
|
||||||
// 'standard' on every boot so an old queue row doesn't strand us.
|
|
||||||
try {
|
|
||||||
await db.execute(
|
|
||||||
sql`UPDATE pgboss.queue SET policy = 'standard' WHERE name = ${REMINDER_FIRE_QUEUE} AND policy <> 'standard'`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ err },
|
|
||||||
"reminder.fire: failed to force queue policy=standard (handler-level dedupe still applies)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await boss.work<FireReminderPayload>(
|
await boss.work<FireReminderPayload>(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{
|
{
|
||||||
@ -61,33 +43,6 @@ export async function scheduleReminderFire(
|
|||||||
reminderId: string,
|
reminderId: string,
|
||||||
scheduledAt: Date,
|
scheduledAt: Date,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const singletonKey = `reminder:${reminderId}`;
|
|
||||||
// Replace-then-send. Any 'created' (i.e. not yet started) job for
|
|
||||||
// this reminder is the stale next-fire from the previous schedule
|
|
||||||
// attempt; nuke it so the new schedule wins. Active/completed jobs
|
|
||||||
// are left alone — those represent in-flight or already-fired runs
|
|
||||||
// and the handler-level dedupe handles overlap.
|
|
||||||
try {
|
|
||||||
const cancelled = await db.execute(
|
|
||||||
sql`UPDATE pgboss.job
|
|
||||||
SET state = 'cancelled', completed_on = now()
|
|
||||||
WHERE name = ${REMINDER_FIRE_QUEUE}
|
|
||||||
AND singleton_key = ${singletonKey}
|
|
||||||
AND state = 'created'
|
|
||||||
RETURNING id`,
|
|
||||||
);
|
|
||||||
if (cancelled.rows.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
{ reminderId, cancelled: cancelled.rows.length },
|
|
||||||
"reminder.fire: cancelled stale created jobs before reschedule",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// If the cancellation step fails, log but still try to send. Worst
|
|
||||||
// case we end up with two created jobs and the handler-level
|
|
||||||
// recent-run dedupe drops the duplicate fire.
|
|
||||||
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
|
|
||||||
}
|
|
||||||
const id = await boss.send(
|
const id = await boss.send(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{ reminderId },
|
{ reminderId },
|
||||||
@ -96,10 +51,8 @@ export async function scheduleReminderFire(
|
|||||||
retryLimit: 3,
|
retryLimit: 3,
|
||||||
retryDelay: 30,
|
retryDelay: 30,
|
||||||
retryBackoff: true,
|
retryBackoff: true,
|
||||||
// Singleton key kept on the job row for diagnostics + the
|
// Use the reminderId as a singleton key so re-scheduling cancels the old job
|
||||||
// pre-send cancel above, even though 'standard' policy doesn't
|
singletonKey: `reminder:${reminderId}`,
|
||||||
// dedupe by it.
|
|
||||||
singletonKey,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
||||||
|
|||||||
@ -7,45 +7,35 @@ import { logger } from "../logger.js";
|
|||||||
export async function syncGroupsForAccount(
|
export async function syncGroupsForAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
socket: WASocket,
|
socket: WASocket,
|
||||||
): Promise<{ synced: number; archived: number }> {
|
): Promise<{ synced: number; removed: number }> {
|
||||||
const meta = await socket.groupFetchAllParticipating();
|
const meta = await socket.groupFetchAllParticipating();
|
||||||
const entries = Object.values(meta);
|
const entries = Object.values(meta);
|
||||||
const liveJids = entries.map((g) => g.id);
|
const liveJids = entries.map((g) => g.id);
|
||||||
|
|
||||||
// Mark DB rows as archived when they're no longer in the live
|
// Remove DB rows for groups that are no longer in the live participant list
|
||||||
// participant list (group deleted, bot removed, etc). We don't
|
// (group was deleted, bot was removed, etc.). Only run the delete when we
|
||||||
// physically DELETE because reminder_targets.group_id is a NOT
|
// got at least one live group back — an empty result is more likely a
|
||||||
// NULL FK to this row — a hard delete throws "violates foreign
|
// transient WA fetch failure than a genuine "all groups gone" signal, and
|
||||||
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
|
// we don't want to nuke valid data on a hiccup.
|
||||||
// and aborts the WHOLE group-sync transaction (which then strands
|
let removed: { id: string }[] = [];
|
||||||
// the post-pair open event and the operator sees it as a failed
|
|
||||||
// pairing). Soft-archive keeps reminders that targeted the group
|
|
||||||
// intact and gives the operator the option to clean them up
|
|
||||||
// explicitly later. Only run the sweep when we got at least one
|
|
||||||
// live group back — an empty result is usually a transient WA
|
|
||||||
// fetch failure and we don't want to mass-archive valid data.
|
|
||||||
let archived = 0;
|
|
||||||
if (liveJids.length > 0) {
|
if (liveJids.length > 0) {
|
||||||
const rows = await db
|
removed = await db
|
||||||
.update(whatsappGroups)
|
.delete(whatsappGroups)
|
||||||
.set({ isArchived: true, lastSyncedAt: new Date() })
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(whatsappGroups.accountId, accountId),
|
eq(whatsappGroups.accountId, accountId),
|
||||||
notInArray(whatsappGroups.waGroupJid, liveJids),
|
notInArray(whatsappGroups.waGroupJid, liveJids),
|
||||||
eq(whatsappGroups.isArchived, false),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning({ id: whatsappGroups.id });
|
.returning({ id: whatsappGroups.id });
|
||||||
archived = rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId },
|
{ accountId },
|
||||||
"group-sync: empty fetch — skipping archive sweep (treating as transient)",
|
"group-sync: empty fetch — skipping delete sweep (treating as transient)",
|
||||||
);
|
);
|
||||||
return { synced: 0, archived: 0 };
|
return { synced: 0, removed: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = entries.map((g) => ({
|
const rows = entries.map((g) => ({
|
||||||
@ -66,16 +56,12 @@ export async function syncGroupsForAccount(
|
|||||||
name: sql`excluded.name`,
|
name: sql`excluded.name`,
|
||||||
participantCount: sql`excluded.participant_count`,
|
participantCount: sql`excluded.participant_count`,
|
||||||
lastSyncedAt: sql`excluded.last_synced_at`,
|
lastSyncedAt: sql`excluded.last_synced_at`,
|
||||||
// If a previously-archived group reappears in the live list
|
|
||||||
// (operator was re-added, group was un-deleted, etc.), flip
|
|
||||||
// the flag back so it shows up in the picker again.
|
|
||||||
isArchived: sql`excluded.is_archived`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId, count: rows.length, archived },
|
{ accountId, count: rows.length, removed: removed.length },
|
||||||
"group-sync: synced",
|
"group-sync: synced",
|
||||||
);
|
);
|
||||||
return { synced: rows.length, archived };
|
return { synced: rows.length, removed: removed.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,44 +120,6 @@ class SessionManager {
|
|||||||
this.sessions.delete(accountId);
|
this.sessions.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tell WhatsApp to remove this device from the linked-devices list,
|
|
||||||
* then close the socket. Used by the delete-account flow so the
|
|
||||||
* operator's phone doesn't keep showing a phantom "linked device"
|
|
||||||
* pointing at a row that no longer exists. Best-effort: if the
|
|
||||||
* socket is already torn down or the logout RPC fails (network
|
|
||||||
* blip, already-disconnected, etc.) we still proceed to close +
|
|
||||||
* teardown — no point stranding the delete because WhatsApp didn't
|
|
||||||
* acknowledge.
|
|
||||||
*/
|
|
||||||
async logoutAndStop(accountId: string): Promise<void> {
|
|
||||||
const timer = this.reconnectTimers.get(accountId);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.reconnectTimers.delete(accountId);
|
|
||||||
}
|
|
||||||
const session = this.sessions.get(accountId);
|
|
||||||
if (!session) return;
|
|
||||||
// Suppress reconnect/handleEvent bookkeeping for the close that
|
|
||||||
// logout() emits — the row is about to be deleted entirely so
|
|
||||||
// status writes are pointless.
|
|
||||||
this.intentionalStops.add(accountId);
|
|
||||||
try {
|
|
||||||
await session.socket.logout();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ err, accountId },
|
|
||||||
"session-manager: socket.logout() failed (continuing with teardown)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await session.close();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
|
|
||||||
}
|
|
||||||
this.sessions.delete(accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
# Required
|
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/dbname
|
|
||||||
|
|
||||||
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
|
|
||||||
AUTH_SECRET=replace-me
|
|
||||||
|
|
||||||
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
|
|
||||||
OPERATOR_TOKEN_VERSION=1
|
|
||||||
|
|
||||||
# File-storage paths inside the bot container
|
|
||||||
DATA_DIR=/data
|
|
||||||
SESSIONS_DIR=/data/sessions
|
|
||||||
MEDIA_DIR=/data/media
|
|
||||||
|
|
||||||
# Bot fan-out tuning (see apps/bot/src/env.ts)
|
|
||||||
BOT_HEALTH_PORT=8081
|
|
||||||
BOT_LOG_LEVEL=info
|
|
||||||
BOT_FIRE_CONCURRENCY=8
|
|
||||||
BOT_GROUP_CONCURRENCY=3
|
|
||||||
BOT_MAX_SEND_PER_MINUTE=40
|
|
||||||
|
|
||||||
# Web
|
|
||||||
WEB_PORT=9000
|
|
||||||
|
|
||||||
# Seed (runs once via scripts/db.sh seed)
|
|
||||||
SEED_OPERATOR_USERNAME=admin
|
|
||||||
SEED_OPERATOR_NAME=Operator
|
|
||||||
@ -21,7 +21,6 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
serverActions: {
|
serverActions: {
|
||||||
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
|
||||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||||
// the action; the per-kind WhatsApp validator
|
// the action; the per-kind WhatsApp validator
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@serwist/next": "^9.5.11",
|
"@serwist/next": "^9.5.11",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
@ -45,7 +44,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@ -172,16 +172,8 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
|
|||||||
.update(whatsappAccounts)
|
.update(whatsappAccounts)
|
||||||
.set({ status: "unpaired", phoneNumber: null })
|
.set({ status: "unpaired", phoneNumber: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
// Soft-archive synced groups instead of DELETEing. Hard delete
|
// Wipe synced groups too — they belong to a different WA login now.
|
||||||
// failed with "violates foreign key constraint
|
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
|
||||||
// reminder_targets_group_id_whatsapp_groups_id_fk" whenever any
|
|
||||||
// group had ever been used in a reminder, which aborted the
|
|
||||||
// unpair. Archived groups vanish from the picker; a re-pair flips
|
|
||||||
// them back via the on-conflict upsert in syncGroupsForAccount.
|
|
||||||
await db
|
|
||||||
.update(whatsappGroups)
|
|
||||||
.set({ isArchived: true })
|
|
||||||
.where(eq(whatsappGroups.accountId, accountId));
|
|
||||||
revalidatePath("/accounts");
|
revalidatePath("/accounts");
|
||||||
revalidatePath(`/accounts/${accountId}`);
|
revalidatePath(`/accounts/${accountId}`);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -201,12 +193,8 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
|
|||||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||||
});
|
});
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
// Tell the bot to logout() over the live socket FIRST (so WhatsApp
|
// Stop any live session / clean session files first.
|
||||||
// drops this device from the operator's linked-devices list), then
|
await pgNotifyBot({ type: "account.unpair", accountId });
|
||||||
// close + remove session files. Distinct from account.unpair which
|
|
||||||
// never calls logout — keeping linked-devices clean is specific to
|
|
||||||
// the delete flow.
|
|
||||||
await pgNotifyBot({ type: "account.delete", accountId });
|
|
||||||
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
|
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
|
||||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
||||||
revalidatePath("/accounts");
|
revalidatePath("/accounts");
|
||||||
|
|||||||
@ -1,367 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
|
|
||||||
const {
|
|
||||||
cookiesSetMock,
|
|
||||||
cookiesDeleteMock,
|
|
||||||
findUserMock,
|
|
||||||
headersGetMock,
|
|
||||||
headerStore,
|
|
||||||
checkRateLimitMock,
|
|
||||||
redirectMock,
|
|
||||||
loggerMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
cookiesSetMock: vi.fn(),
|
|
||||||
cookiesDeleteMock: vi.fn(),
|
|
||||||
findUserMock: vi.fn(),
|
|
||||||
headersGetMock: vi.fn(() => "127.0.0.1"),
|
|
||||||
headerStore: new Map<string, string>(),
|
|
||||||
checkRateLimitMock: vi.fn(),
|
|
||||||
redirectMock: vi.fn((_path: string) => {
|
|
||||||
throw new Error("redirect");
|
|
||||||
}),
|
|
||||||
loggerMock: { warn: vi.fn(), info: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
|
|
||||||
headers: async () => ({
|
|
||||||
get: (k: string) => {
|
|
||||||
const key = k.toLowerCase();
|
|
||||||
if (key === "x-forwarded-for") return headersGetMock();
|
|
||||||
// Tests opt-in to setting origin/host/etc. via headerStore;
|
|
||||||
// unset = null which lets hasSameOriginRequest treat the
|
|
||||||
// request as same-origin (Origin omitted = same-origin per RFC).
|
|
||||||
return headerStore.get(key) ?? null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: (path: string) => redirectMock(path),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
|
|
||||||
|
|
||||||
const SECRET = "test-secret-not-real";
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
cookiesSetMock.mockReset();
|
|
||||||
cookiesDeleteMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
checkRateLimitMock.mockReset();
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
|
|
||||||
redirectMock.mockReset();
|
|
||||||
redirectMock.mockImplementation((_path: string) => {
|
|
||||||
throw new Error("redirect");
|
|
||||||
});
|
|
||||||
loggerMock.warn.mockReset();
|
|
||||||
headerStore.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
import { loginAction, logoutAction } from "./auth";
|
|
||||||
|
|
||||||
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
|
|
||||||
const ADMIN_ROW = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
displayName: "Admin",
|
|
||||||
defaultTimezone: "UTC",
|
|
||||||
passwordHash: REAL_HASH,
|
|
||||||
};
|
|
||||||
|
|
||||||
function fd(fields: Record<string, string>): FormData {
|
|
||||||
const f = new FormData();
|
|
||||||
for (const [k, v] of Object.entries(fields)) f.append(k, v);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("loginAction", () => {
|
|
||||||
it("issues a session cookie when credentials are correct", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prevEnv = process.env.NODE_ENV;
|
|
||||||
// @ts-expect-error - test override
|
|
||||||
process.env.NODE_ENV = "production";
|
|
||||||
try {
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
|
|
||||||
(e) => e,
|
|
||||||
);
|
|
||||||
// Successful login redirects, so the redirect mock throws.
|
|
||||||
expect((r as Error).message).toBe("redirect");
|
|
||||||
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
expect(name).toBe("session");
|
|
||||||
expect(attrs).toMatchObject({
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 30 * 86400,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// @ts-expect-error - test restore
|
|
||||||
process.env.NODE_ENV = prevEnv;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prevEnv = process.env.NODE_ENV;
|
|
||||||
// @ts-expect-error - test override
|
|
||||||
process.env.NODE_ENV = "development";
|
|
||||||
try {
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
expect(attrs).toMatchObject({ secure: false });
|
|
||||||
} finally {
|
|
||||||
// @ts-expect-error - test restore
|
|
||||||
process.env.NODE_ENV = prevEnv;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
expect(loggerMock.warn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(undefined);
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
|
|
||||||
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a clear error when the user has no password_hash set", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "anything" }));
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Set a password via scripts/set-password.sh before signing in.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty username or password without hitting the DB", async () => {
|
|
||||||
const r = await loginAction(fd({ username: "", password: "x" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Username and password are required." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects username/password >256 chars without invoking bcrypt", async () => {
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
const long = "x".repeat(300);
|
|
||||||
const r = await loginAction(fd({ username: long, password: long }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Input too long." });
|
|
||||||
expect(cmpSpy).not.toHaveBeenCalled();
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches username case-insensitively", async () => {
|
|
||||||
findUserMock.mockImplementation(async () => ADMIN_ROW);
|
|
||||||
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 429 when the rate limit is exhausted", async () => {
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs the failed attempt with username and ip but never the password", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
|
|
||||||
await loginAction(fd({ username: "admin", password: "wrong" }));
|
|
||||||
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
|
|
||||||
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
|
|
||||||
expect(JSON.stringify(meta)).not.toContain("wrong");
|
|
||||||
expect(msg).toMatch(/login failed/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects to safeRedirect(next) on success", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
await loginAction(fd({
|
|
||||||
username: "admin",
|
|
||||||
password: "correct-horse",
|
|
||||||
next: "/dashboard",
|
|
||||||
})).catch(() => {});
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects to / when next is unsafe", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
await loginAction(fd({
|
|
||||||
username: "admin",
|
|
||||||
password: "correct-horse",
|
|
||||||
next: "//evil.com",
|
|
||||||
})).catch(() => {});
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("logoutAction", () => {
|
|
||||||
it("clears the session cookie and redirects to /login", async () => {
|
|
||||||
await logoutAction().catch(() => {});
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent — clears the cookie even when no session exists", async () => {
|
|
||||||
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
|
|
||||||
// doesn't care about pre-existing state and we still issue the redirect.
|
|
||||||
cookiesDeleteMock.mockReset();
|
|
||||||
await logoutAction().catch(() => {});
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loginAction — additional cases", () => {
|
|
||||||
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
|
|
||||||
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
// The cookie is now AES-GCM encrypted, so we can't peel the payload
|
|
||||||
// off raw — decrypt with the same secret loginAction used. This
|
|
||||||
// also doubles as a confidentiality smoke test: 'user'/'alice'
|
|
||||||
// must NOT appear verbatim in the cookie bytes.
|
|
||||||
expect(cookieValue as string).not.toContain("alice");
|
|
||||||
expect(cookieValue as string).not.toContain("user");
|
|
||||||
const { verifySession } = await import("@/lib/auth-cookie");
|
|
||||||
const decoded = await verifySession(cookieValue as string, SECRET);
|
|
||||||
expect(decoded?.role).toBe("user");
|
|
||||||
expect(decoded?.userId).toBe(ADMIN_ROW.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the user row has an unrecognised role string", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prev = process.env.AUTH_SECRET;
|
|
||||||
delete process.env.AUTH_SECRET;
|
|
||||||
try {
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
process.env.AUTH_SECRET = prev;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats whitespace-only username as missing input", async () => {
|
|
||||||
const r = await loginAction(fd({ username: " ", password: "x" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Username and password are required." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headersGetMock.mockReturnValue("198.51.100.42");
|
|
||||||
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
// Three checkRateLimit calls fired in parallel via Promise.all,
|
|
||||||
// in this order: ip / user / global.
|
|
||||||
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
|
|
||||||
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
|
|
||||||
expect(keys[0]).toBe("login:198.51.100.42");
|
|
||||||
// Username key is normalised to lowercase so "Admin" and "admin"
|
|
||||||
// share the same bucket — otherwise an attacker rotating case
|
|
||||||
// would dodge per-username throttling.
|
|
||||||
expect(keys[1]).toBe("login-user:admin");
|
|
||||||
expect(keys[2]).toBe("login-global");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
// First call (ip) passes, second (user) is over, third (global) passes.
|
|
||||||
checkRateLimitMock
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: true, count: 6 })
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 5 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
// Logger captures which limit tripped so we can tune thresholds
|
|
||||||
// without leaking the answer to the attacker.
|
|
||||||
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
|
|
||||||
expect(meta).toMatchObject({ limit: "username" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
checkRateLimitMock
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: true, count: 101 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
|
|
||||||
expect(meta).toMatchObject({ limit: "global" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cross-origin POST before checking credentials", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "https://attacker.example");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
|
|
||||||
expect(checkRateLimitMock).not.toHaveBeenCalled();
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "https://wabot.04080616.xyz");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
// Got past the origin check → DB lookup ran.
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
|
|
||||||
// Browsers don't always send Origin (e.g. plain top-level form
|
|
||||||
// submissions). Refusing those would brick login on some clients.
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.delete("origin");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when Origin is malformed (non-URL string)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "not a url");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
|
|
||||||
findUserMock.mockResolvedValue(undefined);
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
await loginAction(fd({ username: "ghost", password: "anything" }));
|
|
||||||
// findFirst was called even though we know the user doesn't exist.
|
|
||||||
expect(findUserMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cmpSpy).toHaveBeenCalled();
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import {
|
|
||||||
COOKIE_NAME,
|
|
||||||
DEFAULT_TTL_SECONDS,
|
|
||||||
signSession,
|
|
||||||
type Role,
|
|
||||||
} from "@/lib/auth-cookie";
|
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
|
||||||
import { safeRedirect } from "@/lib/safe-redirect";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
export type LoginResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
const MAX_FIELD_LEN = 256;
|
|
||||||
|
|
||||||
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
|
|
||||||
// Compared against on the user-not-found path so timing matches the
|
|
||||||
// wrong-password path. Generating fresh per request would double the
|
|
||||||
// bcrypt work and create its own timing signal.
|
|
||||||
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
|
|
||||||
|
|
||||||
async function clientIp(): Promise<string> {
|
|
||||||
const h = await headers();
|
|
||||||
const fwd = h.get("x-forwarded-for");
|
|
||||||
if (fwd) return fwd.split(",")[0]!.trim();
|
|
||||||
return h.get("x-real-ip") ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare the inbound Origin to the request's Host. Server Actions
|
|
||||||
* already get an Origin check via Next 16's
|
|
||||||
* `serverActions.allowedOrigins`, but that's a global config — running
|
|
||||||
* the same comparison here is cheap belt-and-braces and lets us log
|
|
||||||
* mismatches with action-level context. Returns true when:
|
|
||||||
* - no Origin header is present (same-origin POSTs from the same
|
|
||||||
* server), OR
|
|
||||||
* - Origin's host matches the Host header (same-origin)
|
|
||||||
* Anything else (cross-origin POST, malformed Origin, etc.) → false.
|
|
||||||
*/
|
|
||||||
async function hasSameOriginRequest(): Promise<boolean> {
|
|
||||||
const h = await headers();
|
|
||||||
const origin = h.get("origin");
|
|
||||||
if (!origin) return true; // RFC: same-origin requests may omit Origin
|
|
||||||
const host = h.get("host");
|
|
||||||
if (!host) return false;
|
|
||||||
try {
|
|
||||||
const u = new URL(origin);
|
|
||||||
return u.host === host;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginAction(formData: FormData): Promise<LoginResult> {
|
|
||||||
const username = (formData.get("username") ?? "").toString();
|
|
||||||
const password = (formData.get("password") ?? "").toString();
|
|
||||||
const next = (formData.get("next") ?? "").toString();
|
|
||||||
|
|
||||||
if (!username.trim() || !password) {
|
|
||||||
return { ok: false, error: "Username and password are required." };
|
|
||||||
}
|
|
||||||
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
|
|
||||||
return { ok: false, error: "Input too long." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action-level Origin check. Next 16's serverActions.allowedOrigins
|
|
||||||
// already gates this at the framework boundary, but doing it here
|
|
||||||
// with action context lets us log the mismatch and surface a clean
|
|
||||||
// error instead of relying on the global config alone.
|
|
||||||
if (!(await hasSameOriginRequest())) {
|
|
||||||
logger.warn({}, "login rejected: cross-origin request");
|
|
||||||
return { ok: false, error: "Cross-origin request blocked." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = await clientIp();
|
|
||||||
// Three-layer rate limit:
|
|
||||||
// per-IP — typical brute-forcer
|
|
||||||
// per-username — attacker who rotates IPs (X-Forwarded-For
|
|
||||||
// spoofing, residential proxy pool) but pounds
|
|
||||||
// a single account
|
|
||||||
// global — backstop. If the attacker controls enough
|
|
||||||
// IP+username combos to slip past the first two,
|
|
||||||
// this caps the total login attempts per minute
|
|
||||||
// across the install. Lock occurs at the FIRST
|
|
||||||
// limit hit; we don't reveal which one.
|
|
||||||
const usernameKey = username.trim().toLowerCase();
|
|
||||||
const [rlIp, rlUser, rlGlobal] = await Promise.all([
|
|
||||||
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
|
|
||||||
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
|
|
||||||
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
|
|
||||||
]);
|
|
||||||
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
ip,
|
|
||||||
username: usernameKey,
|
|
||||||
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
|
|
||||||
},
|
|
||||||
"login rate-limited",
|
|
||||||
);
|
|
||||||
return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await db.query.operators.findFirst({
|
|
||||||
where: (o) => sql`lower(${o.username}) = lower(${username})`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// User exists but has no password configured: this is a server-side
|
|
||||||
// setup error, not a credential mismatch. Surface a distinct message
|
|
||||||
// so the operator knows to run scripts/set-password.sh. We still ran
|
|
||||||
// the DB lookup, so the username-enumeration concern is not relevant
|
|
||||||
// here (the attacker would already need a known username).
|
|
||||||
if (row && row.passwordHash === null) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Set a password via scripts/set-password.sh before signing in.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run bcrypt regardless to keep the user-not-found path timing-
|
|
||||||
// equivalent to the wrong-password path.
|
|
||||||
const hash = row?.passwordHash ?? DUMMY_HASH;
|
|
||||||
const ok = await bcrypt.compare(password, hash);
|
|
||||||
|
|
||||||
if (!row || !ok) {
|
|
||||||
logger.warn({ username, ip }, "login failed");
|
|
||||||
return { ok: false, error: "Invalid username or password." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.role !== "admin" && row.role !== "user") {
|
|
||||||
return { ok: false, error: "Account is not enabled." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = process.env.AUTH_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
|
|
||||||
return { ok: false, error: "Server is not configured for sign-in." };
|
|
||||||
}
|
|
||||||
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const cookie = await signSession(
|
|
||||||
{
|
|
||||||
userId: row.id,
|
|
||||||
role: row.role as Role,
|
|
||||||
iat: now,
|
|
||||||
exp: now + DEFAULT_TTL_SECONDS,
|
|
||||||
v,
|
|
||||||
},
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
const jar = await cookies();
|
|
||||||
// Secure: only require https in production. In dev we hit
|
|
||||||
// http://localhost:9000 directly, and Firefox/Safari silently drop
|
|
||||||
// Set-Cookie when Secure is set on http origins (Chrome has a
|
|
||||||
// localhost exception, others don't), which manifested as the
|
|
||||||
// session cookie never being persisted across requests.
|
|
||||||
jar.set(COOKIE_NAME, cookie, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: DEFAULT_TTL_SECONDS,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
|
|
||||||
// `next` value is a runtime string from the form so we cast through any.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
redirect(safeRedirect(next) as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logoutAction(): Promise<void> {
|
|
||||||
const jar = await cookies();
|
|
||||||
jar.delete(COOKIE_NAME);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
redirect("/login" as any);
|
|
||||||
}
|
|
||||||
@ -33,12 +33,6 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
|
|||||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupId = parsed.data.groupId;
|
|
||||||
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
|
|
||||||
if (groupRl.limited) {
|
|
||||||
return { ok: false, error: "Too many tests for this group. Try again later." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const group = await db.query.whatsappGroups.findFirst({
|
const group = await db.query.whatsappGroups.findFirst({
|
||||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
||||||
|
|||||||
@ -271,7 +271,7 @@ const createReminderSchema = z
|
|||||||
path: ["messages"],
|
path: ["messages"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
|
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
|
||||||
message: "Delivery window start must be earlier than end",
|
message: "Delivery window start must be earlier than end",
|
||||||
path: ["deliveryWindowStartHour"],
|
path: ["deliveryWindowStartHour"],
|
||||||
});
|
});
|
||||||
@ -328,11 +328,7 @@ export async function createReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||||
// operator hasn't ticked the optional "Pause sending by" checkbox;
|
|
||||||
// fall back to 24 here so legacy payloads / direct API calls don't
|
|
||||||
// accidentally enable the deadline at 6pm.
|
|
||||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
|
|
||||||
const parts = resolveMessageParts(parsed.data);
|
const parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -446,11 +442,7 @@ export async function updateReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||||
// operator hasn't ticked the optional "Pause sending by" checkbox;
|
|
||||||
// fall back to 24 here so legacy payloads / direct API calls don't
|
|
||||||
// accidentally enable the deadline at 6pm.
|
|
||||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
|
|
||||||
const parts = resolveMessageParts(parsed.data);
|
const parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -571,12 +563,6 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
|
|||||||
export async function resumeReminderRunAction(input: {
|
export async function resumeReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<ResumeReminderRunResult> {
|
}): Promise<ResumeReminderRunResult> {
|
||||||
const ip =
|
|
||||||
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
|
||||||
if (rl.limited) {
|
|
||||||
return { ok: false, error: "Too many requests. Try again later." };
|
|
||||||
}
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -627,12 +613,6 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
|
|||||||
export async function cancelReminderRunAction(input: {
|
export async function cancelReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<CancelReminderRunResult> {
|
}): Promise<CancelReminderRunResult> {
|
||||||
const ip =
|
|
||||||
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
|
||||||
if (rl.limited) {
|
|
||||||
return { ok: false, error: "Too many requests. Try again later." };
|
|
||||||
}
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@ -1,192 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const {
|
|
||||||
requireAdminMock,
|
|
||||||
findUserMock,
|
|
||||||
findManyAdminsMock,
|
|
||||||
insertReturningMock,
|
|
||||||
updateMock,
|
|
||||||
deleteMock,
|
|
||||||
checkRateLimitMock,
|
|
||||||
revalidateMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
requireAdminMock: vi.fn(),
|
|
||||||
findUserMock: vi.fn(),
|
|
||||||
findManyAdminsMock: vi.fn(),
|
|
||||||
insertReturningMock: vi.fn(),
|
|
||||||
updateMock: vi.fn(),
|
|
||||||
deleteMock: vi.fn(),
|
|
||||||
checkRateLimitMock: vi.fn(),
|
|
||||||
revalidateMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/auth", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
requireAdmin: () => requireAdminMock(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: {
|
|
||||||
findFirst: (...a: unknown[]) => findUserMock(...a),
|
|
||||||
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
|
|
||||||
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
|
|
||||||
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
headers: async () => ({ get: () => "127.0.0.1" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
requireAdminMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
findManyAdminsMock.mockReset();
|
|
||||||
insertReturningMock.mockReset();
|
|
||||||
updateMock.mockReset();
|
|
||||||
deleteMock.mockReset();
|
|
||||||
checkRateLimitMock.mockReset();
|
|
||||||
revalidateMock.mockReset();
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const ADMIN = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
};
|
|
||||||
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
|
|
||||||
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
|
|
||||||
|
|
||||||
import {
|
|
||||||
createUserAction,
|
|
||||||
setUserRoleAction,
|
|
||||||
resetUserPasswordAction,
|
|
||||||
deleteUserAction,
|
|
||||||
} from "./users";
|
|
||||||
|
|
||||||
describe("createUserAction", () => {
|
|
||||||
it("admin can create a user with role 'user'", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
|
|
||||||
const r = await createUserAction({
|
|
||||||
username: "bob",
|
|
||||||
password: "longpw1",
|
|
||||||
role: "user",
|
|
||||||
});
|
|
||||||
expect(r).toEqual({ ok: true, userId: USER.id });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects username/password under length limits", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setUserRoleAction — self-demote guard", () => {
|
|
||||||
it("admin demoting themselves is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "You can't demote your own account.",
|
|
||||||
});
|
|
||||||
expect(updateMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin demoting another admin is allowed when others remain", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
|
|
||||||
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin demoting the last remaining admin is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
|
|
||||||
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteUserAction", () => {
|
|
||||||
it("admin deleting themselves is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await deleteUserAction({ userId: ADMIN.id });
|
|
||||||
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
|
|
||||||
expect(deleteMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin deleting another user is allowed", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
|
|
||||||
const r = await deleteUserAction({ userId: USER.id });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin deleting the last admin is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
|
|
||||||
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resetUserPasswordAction", () => {
|
|
||||||
it("admin can reset another user's password", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
expect(updateMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects too-short passwords", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects letters-only passwords (no number or symbol)", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({
|
|
||||||
userId: USER.id,
|
|
||||||
newPassword: "abcdefghij",
|
|
||||||
});
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Password must mix letters with numbers or symbols.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects digits-only passwords", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({
|
|
||||||
userId: USER.id,
|
|
||||||
newPassword: "1234567890",
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { operators } from "@cmbot/db";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { requireAdmin } from "@/lib/auth";
|
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
|
||||||
import { validatePassword } from "@/lib/password-policy";
|
|
||||||
|
|
||||||
const MAX_FIELD_LEN = 256;
|
|
||||||
|
|
||||||
async function rateLimit(key: string): Promise<{ limited: boolean }> {
|
|
||||||
const h = await headers();
|
|
||||||
const ip =
|
|
||||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateUserResult =
|
|
||||||
| { ok: true; userId: string }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function createUserAction(input: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
}): Promise<CreateUserResult> {
|
|
||||||
await requireAdmin();
|
|
||||||
const rl = await rateLimit("create-user");
|
|
||||||
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
const u = input.username.trim();
|
|
||||||
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
|
|
||||||
return { ok: false, error: "Username must be 3..256 chars." };
|
|
||||||
}
|
|
||||||
const pwCheck = validatePassword(input.password);
|
|
||||||
if (!pwCheck.ok) return pwCheck;
|
|
||||||
if (input.role !== "admin" && input.role !== "user") {
|
|
||||||
return { ok: false, error: "Role must be admin or user." };
|
|
||||||
}
|
|
||||||
const hash = await bcrypt.hash(input.password, 12);
|
|
||||||
const [row] = await db
|
|
||||||
.insert(operators)
|
|
||||||
.values({
|
|
||||||
username: u,
|
|
||||||
passwordHash: hash,
|
|
||||||
displayName: u,
|
|
||||||
role: input.role,
|
|
||||||
defaultTimezone: "Asia/Kuala_Lumpur",
|
|
||||||
})
|
|
||||||
.returning({ id: operators.id });
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true, userId: row!.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SetRoleResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function setUserRoleAction(input: {
|
|
||||||
userId: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
}): Promise<SetRoleResult> {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
if (input.userId === me.id && input.role !== "admin") {
|
|
||||||
return { ok: false, error: "You can't demote your own account." };
|
|
||||||
}
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
|
|
||||||
// If we're demoting an admin, make sure at least one admin remains.
|
|
||||||
if (target.role === "admin" && input.role !== "admin") {
|
|
||||||
const admins = await db.query.operators.findMany({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
||||||
});
|
|
||||||
if (admins.length <= 1) {
|
|
||||||
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(operators)
|
|
||||||
.set({ role: input.role })
|
|
||||||
.where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function deleteUserAction(input: {
|
|
||||||
userId: string;
|
|
||||||
}): Promise<DeleteUserResult> {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
if (input.userId === me.id) {
|
|
||||||
return { ok: false, error: "You can't delete your own account." };
|
|
||||||
}
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
if (target.role === "admin") {
|
|
||||||
const admins = await db.query.operators.findMany({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
||||||
});
|
|
||||||
if (admins.length <= 1) {
|
|
||||||
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await db.delete(operators).where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function resetUserPasswordAction(input: {
|
|
||||||
userId: string;
|
|
||||||
newPassword: string;
|
|
||||||
}): Promise<ResetPasswordResult> {
|
|
||||||
await requireAdmin();
|
|
||||||
const rl = await rateLimit("reset-password");
|
|
||||||
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
const pwCheck = validatePassword(input.newPassword);
|
|
||||||
if (!pwCheck.ok) return pwCheck;
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
const hash = await bcrypt.hash(input.newPassword, 12);
|
|
||||||
await db
|
|
||||||
.update(operators)
|
|
||||||
.set({ passwordHash: hash })
|
|
||||||
.where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { deleteAccountAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface DeleteAccountCardProps {
|
|
||||||
accountId: string;
|
|
||||||
accountLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteAccountCard({
|
|
||||||
accountId,
|
|
||||||
accountLabel,
|
|
||||||
}: DeleteAccountCardProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await deleteAccountAction(fd);
|
|
||||||
setOpen(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Card
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Delete account"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-destructive">
|
|
||||||
Delete Account
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Remove the account and all its reminders, groups, and history
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{accountLabel}</strong> will be removed along with its
|
|
||||||
synced groups, scheduled reminders, and all run history. This
|
|
||||||
cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={confirm}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
)}
|
|
||||||
Yes, delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,6 +4,7 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
Users2Icon,
|
Users2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -15,7 +16,6 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listGroupsForAccount } from "@/lib/queries";
|
import { listGroupsForAccount } from "@/lib/queries";
|
||||||
import { RefreshGroupsClient } from "./refresh-groups-client";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -57,7 +57,13 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RefreshGroupsClient accountId={account.id} />
|
{/* Refresh button — no-op placeholder, wired in Task 17 */}
|
||||||
|
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="shrink-0">
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Refresh Groups
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Loader2Icon, RefreshCwIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useEvents } from "@/hooks/use-events";
|
|
||||||
import { syncGroupsAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface RefreshGroupsClientProps {
|
|
||||||
accountId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two-stage refresh button:
|
|
||||||
* 1. Click → server action pgNotifies the bot to start a sync.
|
|
||||||
* 2. Bot finishes → emits `groups.synced` over SSE → router.refresh()
|
|
||||||
* re-fetches the page so the new rows appear without the operator
|
|
||||||
* having to reload manually.
|
|
||||||
*
|
|
||||||
* The button stays in its "syncing" state until either the
|
|
||||||
* `groups.synced` event arrives for this account or 15 s pass (so a
|
|
||||||
* disconnected bot doesn't strand the spinner forever).
|
|
||||||
*/
|
|
||||||
export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [waiting, setWaiting] = useState(false);
|
|
||||||
|
|
||||||
useEvents({
|
|
||||||
"groups.synced": (data) => {
|
|
||||||
if (data.accountId !== accountId) return;
|
|
||||||
setWaiting(false);
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function trigger() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await syncGroupsAction(fd);
|
|
||||||
setWaiting(true);
|
|
||||||
// Belt-and-braces: if the bot is unreachable or the SSE channel
|
|
||||||
// drops, drop the spinner after 15 s instead of leaving it stuck.
|
|
||||||
window.setTimeout(() => setWaiting(false), 15_000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const busy = pending || waiting;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={trigger}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{busy ? "Syncing…" : "Refresh Groups"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
Trash2Icon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
|
PowerOffIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -18,12 +20,23 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getAccount } from "@/lib/queries";
|
import { getAccount } from "@/lib/queries";
|
||||||
import { pairAccountAction } from "@/actions/accounts";
|
import {
|
||||||
import { DeleteAccountCard } from "./delete-account-card";
|
unpairAccountAction,
|
||||||
import { UnpairAccountCard } from "./unpair-account-card";
|
pairAccountAction,
|
||||||
|
deleteAccountAction,
|
||||||
|
} from "@/actions/accounts";
|
||||||
|
|
||||||
interface AccountDetailPageProps {
|
interface AccountDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -143,11 +156,102 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<UnpairAccountCard accountId={account.id} accountLabel={account.label} />
|
{/* Unpair — transparent <button> overlay opens the dialog
|
||||||
|
so we don't pass button-specific props onto the Card div
|
||||||
|
(Radix asChild does that and it produces a hydration
|
||||||
|
mismatch on a div). */}
|
||||||
|
<Dialog>
|
||||||
|
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Unpair</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Unpair WhatsApp"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Unpair this account?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> will disconnect from WhatsApp and
|
||||||
|
scheduled reminders using it will stop firing until you re-pair.
|
||||||
|
The account itself is kept; reminders and other data are not deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={unpairAccountAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="default" size="sm">
|
||||||
|
<PowerOffIcon />
|
||||||
|
Yes, unpair
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
|
{/* Delete — transparent <button> overlay opens the dialog. */}
|
||||||
|
<Dialog>
|
||||||
|
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<Trash2Icon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remove the account and all its reminders, groups, and history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete account"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this account permanently?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> will be removed along with its
|
||||||
|
synced groups, scheduled reminders, and all run history. This cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={deleteAccountAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { unpairAccountAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface UnpairAccountCardProps {
|
|
||||||
accountId: string;
|
|
||||||
accountLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnpairAccountCard({
|
|
||||||
accountId,
|
|
||||||
accountLabel,
|
|
||||||
}: UnpairAccountCardProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await unpairAccountAction(fd);
|
|
||||||
setOpen(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Card
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Unpair WhatsApp"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
|
||||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Unpair</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Unpair this account?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{accountLabel}</strong> will disconnect from WhatsApp and
|
|
||||||
scheduled reminders using it will stop firing until you re-pair.
|
|
||||||
The account itself is kept; reminders and other data are not
|
|
||||||
deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={confirm}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<PowerOffIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Yes, unpair
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -14,6 +14,15 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -29,6 +38,7 @@ import { getSeededOperator } from "@/lib/operator";
|
|||||||
import { listActivityRuns } from "@/lib/queries";
|
import { listActivityRuns } from "@/lib/queries";
|
||||||
import {
|
import {
|
||||||
archiveRunAction,
|
archiveRunAction,
|
||||||
|
clearHistoryAction,
|
||||||
deleteRunAction,
|
deleteRunAction,
|
||||||
unarchiveRunAction,
|
unarchiveRunAction,
|
||||||
} from "@/actions/history";
|
} from "@/actions/history";
|
||||||
@ -96,24 +106,24 @@ function RunStatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterValue = "success" | "paused" | "failed" | "archived";
|
type FilterValue =
|
||||||
|
| "all"
|
||||||
|
| "success"
|
||||||
|
| "paused"
|
||||||
|
| "partial"
|
||||||
|
| "failed"
|
||||||
|
| "skipped"
|
||||||
|
| "archived";
|
||||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
{ value: "success", label: "Success" },
|
{ value: "success", label: "Success" },
|
||||||
{ value: "paused", label: "Paused" },
|
{ value: "paused", label: "Paused" },
|
||||||
|
{ value: "partial", label: "Partial" },
|
||||||
{ value: "failed", label: "Failed" },
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "skipped", label: "Skipped" },
|
||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Partial runs (some recipients ok, some failed) surface under BOTH the
|
|
||||||
// Paused and Failed tabs — the operator wants to see anything that didn't
|
|
||||||
// fully succeed on either page. Skipped runs collapse into Archived since
|
|
||||||
// they're effectively "history that the operator chose not to send".
|
|
||||||
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
|
|
||||||
success: ["success"],
|
|
||||||
paused: ["paused", "partial"],
|
|
||||||
failed: ["failed", "partial"],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ filter?: string }>;
|
searchParams: Promise<{ filter?: string }>;
|
||||||
}
|
}
|
||||||
@ -175,41 +185,76 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
const filter: FilterValue =
|
const filter: FilterValue =
|
||||||
sp.filter === "success" ||
|
sp.filter === "success" ||
|
||||||
sp.filter === "paused" ||
|
sp.filter === "paused" ||
|
||||||
|
sp.filter === "partial" ||
|
||||||
sp.filter === "failed" ||
|
sp.filter === "failed" ||
|
||||||
|
sp.filter === "skipped" ||
|
||||||
sp.filter === "archived"
|
sp.filter === "archived"
|
||||||
? sp.filter
|
? sp.filter
|
||||||
: "success";
|
: "all";
|
||||||
const showingArchived = filter === "archived";
|
const showingArchived = filter === "archived";
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
||||||
const filtered =
|
const filtered =
|
||||||
filter === "archived"
|
filter === "all" || filter === "archived"
|
||||||
? runs
|
? runs
|
||||||
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
|
: runs.filter((r) => r.status === filter);
|
||||||
const hasAny = runs.length > 0;
|
const hasAny = runs.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Activity">
|
<PageShell
|
||||||
{/* Filter tabs span the full row and wrap onto a second line when the
|
title="Activity"
|
||||||
viewport can't fit them all. Each trigger has a small basis so they
|
action={
|
||||||
share space evenly while still keeping a readable label on mobile. */}
|
hasAny && !showingArchived ? (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
Clear history
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clear all run history?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This permanently removes every reminder run record, including
|
||||||
|
runs from reminders that have already been deleted. Reminders
|
||||||
|
themselves are not affected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={clearHistoryAction}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, clear history
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
|
||||||
|
packed into a phone-width row left every label squeezed to
|
||||||
|
~50px. Wrap the list in an overflow-x scroller so each tab
|
||||||
|
keeps a readable label + comfortable touch target on mobile;
|
||||||
|
on desktop the row fits naturally and no scroll bar appears.
|
||||||
|
Negative margins extend the scroller to the page edges so the
|
||||||
|
first/last tabs don't look clipped against the container. */}
|
||||||
<Tabs value={filter}>
|
<Tabs value={filter}>
|
||||||
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
|
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{FILTER_TABS.map(({ value, label }) => (
|
<TabsList>
|
||||||
<TabsTrigger
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
key={value}
|
<TabsTrigger key={value} value={value} asChild>
|
||||||
value={value}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
asChild
|
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||||
className="h-8 grow basis-20"
|
{label}
|
||||||
>
|
</Link>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
</TabsTrigger>
|
||||||
<Link href={`/activity?filter=${value}` as any}>
|
))}
|
||||||
{label}
|
</TabsList>
|
||||||
</Link>
|
</div>
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{filtered.length > 0 ? (
|
{filtered.length > 0 ? (
|
||||||
@ -377,7 +422,11 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ActivityIcon}
|
icon={ActivityIcon}
|
||||||
title={
|
title={
|
||||||
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
|
filter === "all"
|
||||||
|
? "No activity yet."
|
||||||
|
: showingArchived
|
||||||
|
? "No archived runs."
|
||||||
|
: `No ${filter} runs yet.`
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
hasAny
|
hasAny
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
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,14 +4,12 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { NotificationManager } from "@/components/notification-manager";
|
import { NotificationManager } from "@/components/notification-manager";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "cm WhatsApp Bot",
|
title: "cm WhatsApp Bot",
|
||||||
description: "Self-hosted WhatsApp reminder bot",
|
description: "Self-hosted WhatsApp reminder bot",
|
||||||
applicationName: "cm WhatsApp Bot",
|
applicationName: "cm WhatsApp Bot",
|
||||||
robots: { index: false, follow: false },
|
|
||||||
// PWA wiring: the manifest comes from the dynamic route at
|
// PWA wiring: the manifest comes from the dynamic route at
|
||||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
||||||
@ -34,13 +32,7 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
// Pass the role into AppShell so the nav can hide admin-only entries
|
|
||||||
// for the 'user' role. On /login getCurrentUser returns null and
|
|
||||||
// AppShell short-circuits to the bare header anyway.
|
|
||||||
const me = await getCurrentUser();
|
|
||||||
const role = me?.role ?? null;
|
|
||||||
const username = me?.username ?? null;
|
|
||||||
return (
|
return (
|
||||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||||
@ -53,7 +45,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppShell role={role} username={username}>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||||
<NotificationManager />
|
<NotificationManager />
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { Loader2Icon, LockIcon, HelpCircleIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { loginAction } from "@/actions/auth";
|
|
||||||
|
|
||||||
export function LoginFormClient({ next }: { next: string }) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handle(formData: FormData) {
|
|
||||||
formData.append("next", next);
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await loginAction(formData);
|
|
||||||
// On success, the action redirects (no return). If we land here,
|
|
||||||
// something failed and `r` is the error shape.
|
|
||||||
if (r && !r.ok) setError(r.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={handle} className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-destructive">{error}</div>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={pending} className="w-full gap-2">
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LockIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircleIcon className="size-3.5" />
|
|
||||||
Forgot password?
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Forgot your password?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Contact your administrator to reset it.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" size="sm">
|
|
||||||
Got it
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { LoginFormClient } from "./login-form-client";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Sign in",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
searchParams: Promise<{ next?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const next = sp.next ?? "/";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center px-4 py-8 min-h-[calc(100dvh-3.5rem)]">
|
|
||||||
<Card className="w-full max-w-sm">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<LoginFormClient next={next} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -217,7 +217,7 @@ export default async function DashboardPage() {
|
|||||||
themselves are not affected.
|
themselves are not affected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter showCloseButton>
|
||||||
<form action={clearHistoryAction}>
|
<form action={clearHistoryAction}>
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
|
|||||||
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter showCloseButton>
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@ -230,28 +230,12 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{reminder.rrule && reminder.scheduledAt ? (
|
||||||
// Single-line summary with mid-string ellipsis. Long
|
<p className="flex items-center gap-1.5 text-xs text-primary/80">
|
||||||
// descriptions ("Every month on days 4, 6, 11, 13, 18,
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
// 20 +2 more at 11:32") truncate cleanly via `truncate`
|
{describeRecurrence(
|
||||||
// (overflow-hidden + text-ellipsis + whitespace-nowrap)
|
|
||||||
// so the card height stays predictable. The native
|
|
||||||
// browser tooltip on `title` lets the operator read
|
|
||||||
// the full string without leaving the page; the edit
|
|
||||||
// form is the canonical full view.
|
|
||||||
<p
|
|
||||||
className="flex items-center gap-1.5 text-xs text-primary/80"
|
|
||||||
title={describeRecurrence(
|
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<RepeatIcon className="size-3 shrink-0" />
|
|
||||||
<span className="truncate min-w-0">
|
|
||||||
{describeRecurrence(
|
|
||||||
specFromRrule(reminder.rrule),
|
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">One-off</p>
|
<p className="text-xs text-muted-foreground">One-off</p>
|
||||||
|
|||||||
@ -247,30 +247,15 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right meta column. Capped at ~14rem so a long
|
<div className="shrink-0 text-right space-y-1">
|
||||||
recurrence description ("Every month on days
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
4, 6, 7, 11, 13, 14 +6 more at 11:32") can't
|
|
||||||
starve the reminder name on the left. min-w-0
|
|
||||||
+ truncate on each span ellipsises overflow
|
|
||||||
inside the cap. Title tooltip preserves the
|
|
||||||
full text on hover. */}
|
|
||||||
<div className="min-w-0 max-w-[34%] sm:max-w-[9.5rem] text-right space-y-1">
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
|
||||||
<CalendarIcon className="size-3 shrink-0" />
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||||
{formatWhen(reminder.scheduledAt, tz)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{reminder.rrule && reminder.scheduledAt ? (
|
||||||
<div
|
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
|
||||||
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
|
|
||||||
title={describeRecurrence(
|
|
||||||
specFromRrule(reminder.rrule),
|
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RepeatIcon className="size-3 shrink-0" />
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>
|
||||||
{describeRecurrence(
|
{describeRecurrence(
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
@ -279,9 +264,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{reminder.groupCount > 0 && (
|
{reminder.groupCount > 0 && (
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
<UsersIcon className="size-3 shrink-0" />
|
<UsersIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>
|
||||||
{reminder.groupCount}{" "}
|
{reminder.groupCount}{" "}
|
||||||
{reminder.groupCount === 1 ? "group" : "groups"}
|
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
import type { MetadataRoute } from "next";
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ import { PageShell } from "@/components/page-shell";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const isAdmin = op.role === "admin";
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Settings" narrow>
|
<PageShell title="Settings" narrow>
|
||||||
<Card>
|
<Card>
|
||||||
@ -15,15 +14,13 @@ export default async function SettingsPage() {
|
|||||||
<CardTitle>Operator</CardTitle>
|
<CardTitle>Operator</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<Row label="Username" value={op.username} mono />
|
<Row label="Display name" value={op.displayName} />
|
||||||
|
<Separator />
|
||||||
|
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
|
||||||
<Separator />
|
<Separator />
|
||||||
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
||||||
{isAdmin && (
|
<Separator />
|
||||||
<>
|
<Row label="Role" value={op.role} mono />
|
||||||
<Separator />
|
|
||||||
<Row label="Role" value={op.role} mono />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -50,6 +47,10 @@ export default async function SettingsPage() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
cm WhatsApp Bot · self-hosted
|
||||||
|
</p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { Loader2Icon, UserPlusIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { createUserAction } from "@/actions/users";
|
|
||||||
|
|
||||||
export function AddUserFormClient() {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [role, setRole] = useState<"admin" | "user">("user");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [ok, setOk] = useState(false);
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
setOk(false);
|
|
||||||
const r = await createUserAction({
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
role,
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
setError(r.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUsername("");
|
|
||||||
setPassword("");
|
|
||||||
setRole("user");
|
|
||||||
setOk(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="new-username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
maxLength={256}
|
|
||||||
placeholder="alice"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="new-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
maxLength={256}
|
|
||||||
placeholder="≥6 chars · letters + number/symbol"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-role">Role</Label>
|
|
||||||
<select
|
|
||||||
id="new-role"
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
|
|
||||||
{ok && (
|
|
||||||
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
User created.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button type="button" size="sm" disabled={pending} onClick={submit}>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<UserPlusIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Add user
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import { requireAdmin } from "@/lib/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { PageShell } from "@/components/page-shell";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { UserRowClient } from "./user-row-client";
|
|
||||||
import { AddUserFormClient } from "./add-user-form-client";
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
const rows = await db.query.operators.findMany({
|
|
||||||
orderBy: (o, { asc }) => [asc(o.username)],
|
|
||||||
});
|
|
||||||
const adminCount = rows.filter((r) => r.role === "admin").length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Users">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add user</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Create a sign-in account. Passwords must be at least 10
|
|
||||||
characters.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<AddUserFormClient />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All users</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Promote a user to admin, demote them back, reset their
|
|
||||||
password, or delete the account. The last admin cannot be
|
|
||||||
demoted or deleted.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{rows.map((u) => (
|
|
||||||
<UserRowClient
|
|
||||||
key={u.id}
|
|
||||||
user={{
|
|
||||||
id: u.id,
|
|
||||||
username: u.username,
|
|
||||||
role: u.role === "admin" ? "admin" : "user",
|
|
||||||
}}
|
|
||||||
isSelf={u.id === me.id}
|
|
||||||
isLastAdmin={u.role === "admin" && adminCount === 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Loader2Icon,
|
|
||||||
Trash2Icon,
|
|
||||||
KeyIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
setUserRoleAction,
|
|
||||||
resetUserPasswordAction,
|
|
||||||
deleteUserAction,
|
|
||||||
} from "@/actions/users";
|
|
||||||
import { validatePassword } from "@/lib/password-policy";
|
|
||||||
|
|
||||||
interface UserRowClientProps {
|
|
||||||
user: { id: string; username: string; role: "admin" | "user" };
|
|
||||||
isSelf: boolean;
|
|
||||||
/** True when this row is the only remaining admin. Disables demote+delete. */
|
|
||||||
isLastAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [resetVisible, setResetVisible] = useState(false);
|
|
||||||
const [resetPw, setResetPw] = useState("");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await promise;
|
|
||||||
if (!r.ok) setError(r.error ?? "Failed");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = user.role === "admin";
|
|
||||||
// The role-toggle button is disabled if:
|
|
||||||
// - flipping yourself (admin self-demotion is rejected server-side too)
|
|
||||||
// - this row is the last remaining admin and would become a user
|
|
||||||
const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin);
|
|
||||||
const deleteDisabled = pending || isSelf || isLastAdmin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
|
||||||
{/* Row 1 — identity: username on the left, role badge + "you"
|
|
||||||
chip on the right, all on one line. */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{user.username}
|
|
||||||
</p>
|
|
||||||
{isSelf && (
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">you</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={
|
|
||||||
isAdmin
|
|
||||||
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
|
|
||||||
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
|
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={roleToggleDisabled}
|
|
||||||
onClick={() =>
|
|
||||||
run(
|
|
||||||
setUserRoleAction({
|
|
||||||
userId: user.id,
|
|
||||||
role: isAdmin ? "user" : "admin",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isAdmin ? (
|
|
||||||
<ArrowDownIcon className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{isAdmin ? "Demote" : "Promote"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => setResetVisible((v) => !v)}
|
|
||||||
>
|
|
||||||
<KeyIcon className="size-3.5" />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={deleteDisabled}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete user @{user.username}?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This permanently removes the account. They will be
|
|
||||||
signed out on their next request and cannot sign in
|
|
||||||
again. This cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteOpen(false);
|
|
||||||
run(deleteUserAction({ userId: user.id }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
Delete user
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
{resetVisible && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="New password (≥6 chars · letters + number/symbol)"
|
|
||||||
value={resetPw}
|
|
||||||
onChange={(e) => setResetPw(e.target.value)}
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending || !validatePassword(resetPw).ok}
|
|
||||||
onClick={() => {
|
|
||||||
run(
|
|
||||||
resetUserPasswordAction({
|
|
||||||
userId: user.id,
|
|
||||||
newPassword: resetPw,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setResetPw("");
|
|
||||||
setResetVisible(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<main>page</main>
|
<main>page</main>
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("brand mark on the left links to /", () => {
|
it("brand mark on the left links to /", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
pathnameMock.mockReturnValue(c.path);
|
pathnameMock.mockReturnValue(c.path);
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
||||||
pathnameMock.mockReturnValue("/unknown-route");
|
pathnameMock.mockReturnValue("/unknown-route");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("renders one nav link per NAV_ITEM, in order", () => {
|
it("renders one nav link per NAV_ITEM, in order", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
it("marks the active route's link with aria-current='page'", () => {
|
it("marks the active route's link with aria-current='page'", () => {
|
||||||
pathnameMock.mockReturnValue("/reminders");
|
pathnameMock.mockReturnValue("/reminders");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
// every page. The header uses an exact-match check for "/".
|
// every page. The header uses an exact-match check for "/".
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("drawer header carries the brand wording and a screen-reader description", () => {
|
it("drawer header carries the brand wording and a screen-reader description", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
|
|
||||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -232,22 +232,21 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a Sign out button in the sidebar footer", () => {
|
it("keeps the theme toggle in the sidebar footer", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
// Theme toggle was dropped from the shell per request; the footer
|
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
|
||||||
// now carries the Sign out affordance + the signed-in username.
|
// appear in the sidebar (we removed it from the mobile drawer).
|
||||||
expect(html).toContain('aria-label="Sign out"');
|
expect(html).toContain('data-testid="theme-toggle"');
|
||||||
expect(html).toContain("admin");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -265,7 +264,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
// reader users on a wide-window split-screen don't hear two
|
// reader users on a wide-window split-screen don't hear two
|
||||||
// identical announcements when both are visible.
|
// identical announcements when both are visible.
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -274,79 +273,6 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Role-gated nav (admin panel)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe("AppShell — role-based nav filtering", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pathnameMock.mockReset();
|
|
||||||
pathnameMock.mockReturnValue("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role="admin" username="admin">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).toContain('href="/settings/users"');
|
|
||||||
// A label appears in both the sidebar and the drawer; either way the
|
|
||||||
// count must be >=2 (sidebar copy + drawer copy).
|
|
||||||
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
|
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Admin entry from BOTH surfaces when role=user", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role="user" username="alice">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
|
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role={null} username={null}>
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
|
|
||||||
const adminHtml = renderToStaticMarkup(
|
|
||||||
<AppShell role="admin" username="admin">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
const userHtml = renderToStaticMarkup(
|
|
||||||
<AppShell role="user" username="alice">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
for (const item of NAV_ITEMS) {
|
|
||||||
if (item.visibleTo) continue;
|
|
||||||
expect(adminHtml).toContain(`href="${item.href}"`);
|
|
||||||
expect(userHtml).toContain(`href="${item.href}"`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
|
|
||||||
pathnameMock.mockReturnValue("/login");
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role={null} username={null}>
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain("<aside");
|
|
||||||
expect(html).not.toContain('data-testid="sheet-content"');
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
expect(html).toContain("WhatsApp Bot");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
|
import { MenuIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logoutAction } from "@/actions/auth";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -15,13 +14,8 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import { NAV_ITEMS } from "@/components/nav-config";
|
||||||
NAV_ITEMS,
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
navItemsForRole,
|
|
||||||
pickActiveNavKey,
|
|
||||||
type NavItem,
|
|
||||||
type NavRole,
|
|
||||||
} from "@/components/nav-config";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mobile header (sm:hidden)
|
// Mobile header (sm:hidden)
|
||||||
@ -36,51 +30,8 @@ import {
|
|||||||
// waiting for the page content to render. The menu button on the right
|
// waiting for the page content to render. The menu button on the right
|
||||||
// opens a Sheet with the full nav list and the theme toggle.
|
// opens a Sheet with the full nav list and the theme toggle.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ---------------------------------------------------------------------------
|
function MobileHeader() {
|
||||||
// Sign-out button used by both the desktop sidebar footer and the mobile
|
|
||||||
// drawer footer. Server-action under the hood: clears the session
|
|
||||||
// cookie and redirects to /login. Disabled while in flight so a
|
|
||||||
// double-click doesn't fire two redirects.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function SignOutButton({ username }: { username: string | null }) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
{username && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
Signed in as <em className="italic font-medium text-foreground">{username}</em>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => start(() => logoutAction())}
|
|
||||||
aria-label="Sign out"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LogOutIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileHeader({
|
|
||||||
items,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
items: NavItem[];
|
|
||||||
username: string | null;
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeKey = pickActiveNavKey(items, pathname);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||||
@ -90,10 +41,6 @@ function MobileHeader({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// Use the full list (not the role-filtered one) for the title lookup
|
|
||||||
// so the page title still shows up correctly when a 'user' role hits
|
|
||||||
// a route they wouldn't normally see in the nav (e.g. arrives via a
|
|
||||||
// direct link), even though they can't navigate there from the menu.
|
|
||||||
const currentItem = NAV_ITEMS.find(({ href }) =>
|
const currentItem = NAV_ITEMS.find(({ href }) =>
|
||||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||||
);
|
);
|
||||||
@ -143,10 +90,10 @@ function MobileHeader({
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
className="flex flex-col gap-0.5 p-2"
|
className="flex flex-col gap-0.5 p-2 flex-1"
|
||||||
>
|
>
|
||||||
{items.map(({ key, href, label, icon: Icon }) => {
|
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = activeKey === key;
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
@ -170,10 +117,6 @@ function MobileHeader({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto border-t border-border p-3">
|
|
||||||
<SignOutButton username={username} />
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</header>
|
</header>
|
||||||
@ -183,15 +126,8 @@ function MobileHeader({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar (desktop only — hidden below sm)
|
// Sidebar (desktop only — hidden below sm)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Sidebar({
|
function Sidebar() {
|
||||||
items,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
items: NavItem[];
|
|
||||||
username: string | null;
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeKey = pickActiveNavKey(items, pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
||||||
@ -214,7 +150,7 @@ function Sidebar({
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||||
{items.map(({ key, href, label, icon: Icon }) => {
|
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -236,74 +172,29 @@ function Sidebar({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer: signed-in user + sign-out */}
|
{/* Footer: theme toggle */}
|
||||||
<div className="border-t border-sidebar-border p-3">
|
<div className="border-t border-sidebar-border p-3">
|
||||||
<SignOutButton username={username} />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
|
|
||||||
// menu, no nav — just the centered brand mark + name. The user explicitly
|
|
||||||
// asked for nothing else here so the sign-in screen feels like a separate
|
|
||||||
// surface from the authenticated app.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function BareHeader() {
|
|
||||||
return (
|
|
||||||
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
|
|
||||||
>
|
|
||||||
cm
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold tracking-tight">
|
|
||||||
WhatsApp Bot
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppShell — the outer container
|
// AppShell — the outer container
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Role of the signed-in user, or null when unauthenticated. */
|
|
||||||
role: NavRole | null;
|
|
||||||
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
|
|
||||||
username: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children, role, username }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isAuthRoute = pathname === "/login";
|
|
||||||
|
|
||||||
if (isAuthRoute) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BareHeader />
|
|
||||||
<main className="min-h-dvh pt-14">{children}</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat unauthenticated render of a protected route (shouldn't happen
|
|
||||||
// because middleware redirects, but defense-in-depth) as 'user': hides
|
|
||||||
// the admin-only entries.
|
|
||||||
const items = navItemsForRole(role ?? "user");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<Sidebar items={items} username={username} />
|
<Sidebar />
|
||||||
|
|
||||||
{/* Mobile header (single row: brand · title · menu) */}
|
{/* Mobile header (single row: brand · title · menu) */}
|
||||||
<MobileHeader items={items} username={username} />
|
<MobileHeader />
|
||||||
|
|
||||||
{/* Main content
|
{/* Main content
|
||||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
|
|
||||||
|
|
||||||
describe("navItemsForRole", () => {
|
|
||||||
it("includes every NAV_ITEM for an admin", () => {
|
|
||||||
const items = navItemsForRole("admin");
|
|
||||||
expect(items).toHaveLength(NAV_ITEMS.length);
|
|
||||||
for (const original of NAV_ITEMS) {
|
|
||||||
expect(items.find((i) => i.key === original.key)).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides admin-only entries for the 'user' role", () => {
|
|
||||||
const items = navItemsForRole("user");
|
|
||||||
const keys = items.map((i) => i.key);
|
|
||||||
expect(keys).not.toContain("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
|
|
||||||
const items = navItemsForRole("user");
|
|
||||||
const keys = items.map((i) => i.key);
|
|
||||||
expect(keys).toEqual(
|
|
||||||
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin nav entry routes to /settings/users", () => {
|
|
||||||
const admin = NAV_ITEMS.find((i) => i.key === "admin");
|
|
||||||
expect(admin).toBeDefined();
|
|
||||||
expect(admin!.href).toBe("/settings/users");
|
|
||||||
expect(admin!.visibleTo).toEqual(["admin"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pickActiveNavKey (longest-match active highlight)", () => {
|
|
||||||
// Use the real NAV_ITEMS so a future href change doesn't silently
|
|
||||||
// re-introduce the regression.
|
|
||||||
const adminItems = navItemsForRole("admin");
|
|
||||||
const userItems = navItemsForRole("user");
|
|
||||||
|
|
||||||
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
|
|
||||||
// Repro of the user-reported regression. Naïve startsWith would
|
|
||||||
// light up both Settings (/settings) and Admin (/settings/users)
|
|
||||||
// because both prefixes match. The longest-match rule must pick
|
|
||||||
// the Admin entry alone.
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings/users");
|
|
||||||
expect(active).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings");
|
|
||||||
expect(active).toBe("settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Settings on a subpath that is NOT /settings/users", () => {
|
|
||||||
// Admin nav is admin-only; this test is just to confirm the
|
|
||||||
// longest-match still picks Settings when no admin descendant
|
|
||||||
// claims the path.
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings/profile");
|
|
||||||
expect(active).toBe("settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
|
|
||||||
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
|
|
||||||
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
|
|
||||||
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
|
|
||||||
// /settings/users isn't visible to a 'user' role, so the helper
|
|
||||||
// must NOT highlight it as Settings just because /settings is a
|
|
||||||
// prefix — we'd be claiming an item is active when the user can't
|
|
||||||
// navigate to it from this nav.
|
|
||||||
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
|
|
||||||
// Neither item's href matches a totally foreign route.
|
|
||||||
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT match a sibling that shares a prefix string", () => {
|
|
||||||
// /settingsfoo is NOT a child of /settings — startsWith would
|
|
||||||
// mistakenly mark Settings active. The strict descendant check
|
|
||||||
// (`href + '/'`) prevents that.
|
|
||||||
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each pathname highlights AT MOST one nav key (defense check)", () => {
|
|
||||||
// Walk a small representative set of routes and confirm we never
|
|
||||||
// light up two items at once. This is the contract the JSX in
|
|
||||||
// app-shell.tsx relies on.
|
|
||||||
const probes = [
|
|
||||||
"/",
|
|
||||||
"/accounts",
|
|
||||||
"/accounts/abc",
|
|
||||||
"/reminders",
|
|
||||||
"/reminders/abc",
|
|
||||||
"/activity",
|
|
||||||
"/activity?filter=success",
|
|
||||||
"/settings",
|
|
||||||
"/settings/users",
|
|
||||||
"/settings/users/something",
|
|
||||||
"/login",
|
|
||||||
"/elsewhere",
|
|
||||||
];
|
|
||||||
for (const path of probes) {
|
|
||||||
const matchCount = adminItems.filter((item) => {
|
|
||||||
if (item.href === "/") return path === "/";
|
|
||||||
return path === item.href || path.startsWith(item.href + "/");
|
|
||||||
}).length;
|
|
||||||
// If two prefixes both match, pickActiveNavKey must collapse
|
|
||||||
// them to one — that's the whole point of the helper.
|
|
||||||
const active = pickActiveNavKey(adminItems, path);
|
|
||||||
if (matchCount === 0) {
|
|
||||||
expect(active).toBeNull();
|
|
||||||
} else {
|
|
||||||
expect(active).not.toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +1,11 @@
|
|||||||
import {
|
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
||||||
Home,
|
|
||||||
Smartphone,
|
|
||||||
Calendar,
|
|
||||||
Activity,
|
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export type NavRole = "admin" | "user";
|
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
/** When set, only roles listed here will see this nav entry. */
|
|
||||||
visibleTo?: NavRole[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
@ -24,54 +13,5 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||||
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
||||||
{
|
|
||||||
key: "admin",
|
|
||||||
href: "/settings/users",
|
|
||||||
label: "Admin",
|
|
||||||
icon: ShieldCheck,
|
|
||||||
visibleTo: ["admin"],
|
|
||||||
},
|
|
||||||
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function navItemsForRole(role: NavRole): NavItem[] {
|
|
||||||
return NAV_ITEMS.filter(
|
|
||||||
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the SINGLE active nav item for a given pathname. Solves the
|
|
||||||
* "Admin and Settings both highlighted on /settings/users" bug:
|
|
||||||
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
|
|
||||||
* Admin entry) AND /settings (its parent). Two items lit up at once
|
|
||||||
* looks broken.
|
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - The Dashboard ('/') item only matches an exact pathname match;
|
|
||||||
* otherwise it would shadow every other route.
|
|
||||||
* - All other items match either an exact pathname or a strict
|
|
||||||
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
|
|
||||||
* its own would also match `/settingsfoo`, which is wrong.
|
|
||||||
* - When two non-root items both match (parent + child), pick the
|
|
||||||
* LONGEST href so the more specific entry wins.
|
|
||||||
*
|
|
||||||
* Returns the active item's `key`, or null if no item matches (e.g.
|
|
||||||
* the user navigated to a route that isn't in the visible nav).
|
|
||||||
*/
|
|
||||||
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
|
|
||||||
let best: NavItem | null = null;
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.href === "/") {
|
|
||||||
if (pathname === "/") best = item;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isMatch =
|
|
||||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
|
||||||
if (!isMatch) continue;
|
|
||||||
if (!best || item.href.length > best.href.length) {
|
|
||||||
best = item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.key ?? null;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,8 +18,7 @@ type PairingState =
|
|||||||
| { phase: "waiting" }
|
| { phase: "waiting" }
|
||||||
| { phase: "qr"; qrUrl: string }
|
| { phase: "qr"; qrUrl: string }
|
||||||
| { phase: "connected"; phoneNumber: string }
|
| { phase: "connected"; phoneNumber: string }
|
||||||
| { phase: "timeout" }
|
| { phase: "timeout" };
|
||||||
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
|
|
||||||
|
|
||||||
interface PairLiveProps {
|
interface PairLiveProps {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -113,15 +112,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
setPairingState({ phase: "timeout" });
|
setPairingState({ phase: "timeout" });
|
||||||
},
|
},
|
||||||
"session.duplicate": (data) => {
|
|
||||||
if (data.accountId !== accountId) return;
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
|
||||||
setPairingState({
|
|
||||||
phase: "duplicate",
|
|
||||||
phoneNumber: data.phoneNumber,
|
|
||||||
existingLabel: data.existingLabel,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-redirect on connected
|
// Auto-redirect on connected
|
||||||
@ -244,35 +234,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pairingState.phase === "duplicate" && (
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
|
|
||||||
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-base font-semibold">Phone already linked</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">
|
|
||||||
+{pairingState.phoneNumber.replace(/^\+/, "")}
|
|
||||||
</span>{" "}
|
|
||||||
is already paired to{" "}
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{pairingState.existingLabel}
|
|
||||||
</span>
|
|
||||||
. Each WhatsApp number can only be linked to one account here.
|
|
||||||
Unpair the existing account first, or scan with a different
|
|
||||||
phone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild size="sm">
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
<Link href={`/accounts/${accountId}` as any}>
|
|
||||||
Back to accounts
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
|
|||||||
id="filter-account"
|
id="filter-account"
|
||||||
value={initial.accountId}
|
value={initial.accountId}
|
||||||
onChange={(e) => setParam("accountId", e.target.value)}
|
onChange={(e) => setParam("accountId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">All accounts</option>
|
<option value="">All accounts</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
|
|||||||
@ -67,11 +67,6 @@ export function SwipeableRow({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
||||||
// Tracks whether the pointer crossed the click-vs-drag threshold during
|
|
||||||
// the current gesture. If it did, we swallow the synthetic click that
|
|
||||||
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
|
|
||||||
// both swipes the shelf open AND navigates to the link target.
|
|
||||||
const dragMoved = useRef(false);
|
|
||||||
|
|
||||||
// Close the shelf when the user taps anywhere outside an open row.
|
// Close the shelf when the user taps anywhere outside an open row.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,17 +92,12 @@ export function SwipeableRow({
|
|||||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||||
dragMoved.current = false;
|
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (!dragging || !dragStart.current) return;
|
if (!dragging || !dragStart.current) return;
|
||||||
const dx = e.clientX - dragStart.current.x;
|
const dx = e.clientX - dragStart.current.x;
|
||||||
// 6 px is the standard threshold below which a touch counts as a tap
|
|
||||||
// rather than a drag. Cross it once and the gesture commits to drag
|
|
||||||
// for the rest of the pointer's lifetime.
|
|
||||||
if (Math.abs(dx) > 6) dragMoved.current = true;
|
|
||||||
setOffset(clamp(dragStart.current.baseOffset + dx));
|
setOffset(clamp(dragStart.current.baseOffset + dx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,28 +113,6 @@ export function SwipeableRow({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (dragMoved.current) {
|
|
||||||
// The browser fires a synthetic `click` on the element under the
|
|
||||||
// pointer right after pointerup. If our row body wraps a <Link>,
|
|
||||||
// that click navigates away. Add a one-shot capture-phase handler
|
|
||||||
// that swallows the next click ANYWHERE in the row container
|
|
||||||
// before it can reach the anchor's onClick.
|
|
||||||
const swallow = (ev: Event) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
};
|
|
||||||
const node = containerRef.current;
|
|
||||||
if (node) {
|
|
||||||
node.addEventListener("click", swallow, { capture: true, once: true });
|
|
||||||
// Defensive: if for some reason no click fires (e.g. pointerup
|
|
||||||
// outside the element), strip the listener after a tick so it
|
|
||||||
// doesn't accidentally eat a future legitimate click.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
node.removeEventListener("click", swallow, { capture: true });
|
|
||||||
}, 350);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragMoved.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -182,14 +150,6 @@ export function SwipeableRow({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
// Anchors (and <img>) are natively draggable. When children
|
|
||||||
// contain a <Link> wrapping the card, the browser hijacks the
|
|
||||||
// pointer for a "drag link" operation as soon as the user
|
|
||||||
// moves horizontally, so the swipe gesture never reaches our
|
|
||||||
// pointer handlers. Suppress native drag here once and the
|
|
||||||
// whole row body is unblocked.
|
|
||||||
onDragStart={(e) => e.preventDefault()}
|
|
||||||
draggable={false}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offset}px)`,
|
transform: `translateX(${offset}px)`,
|
||||||
transition: dragging ? "none" : "transform 200ms ease-out",
|
transition: dragging ? "none" : "transform 200ms ease-out",
|
||||||
|
|||||||
@ -8,25 +8,4 @@ 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,11 +9,6 @@ export type WebEventMap = {
|
|||||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||||
"session.disconnected": { accountId: string };
|
"session.disconnected": { accountId: string };
|
||||||
"session.timeout": { accountId: string };
|
"session.timeout": { accountId: string };
|
||||||
"session.duplicate": {
|
|
||||||
accountId: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
existingLabel: string;
|
|
||||||
};
|
|
||||||
"groups.synced": { accountId: string; count: number };
|
"groups.synced": { accountId: string; count: number };
|
||||||
"reminder.fired": {
|
"reminder.fired": {
|
||||||
reminderId: string;
|
reminderId: string;
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
|
||||||
import {
|
|
||||||
signSession,
|
|
||||||
verifySession,
|
|
||||||
COOKIE_NAME,
|
|
||||||
DEFAULT_TTL_SECONDS,
|
|
||||||
type SessionPayload,
|
|
||||||
} from "./auth-cookie";
|
|
||||||
|
|
||||||
const SECRET = "test-secret-not-used-anywhere-real";
|
|
||||||
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
const validPayload = (): SessionPayload => ({
|
|
||||||
userId: "11111111-1111-1111-1111-111111111111",
|
|
||||||
role: "admin",
|
|
||||||
iat: NOW,
|
|
||||||
exp: NOW + DEFAULT_TTL_SECONDS,
|
|
||||||
v: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth-cookie (AES-256-GCM)", () => {
|
|
||||||
it("signSession + verifySession round-trips a valid payload", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const verified = await verifySession(cookie, SECRET, NOW);
|
|
||||||
expect(verified).toEqual(validPayload());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
|
|
||||||
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
|
|
||||||
// of plaintexts and the auth key). Lock in that signSession draws
|
|
||||||
// a new nonce every time — the byte-for-byte cookies must not match
|
|
||||||
// even when the inputs are identical.
|
|
||||||
const a = await signSession(validPayload(), SECRET);
|
|
||||||
const b = await signSession(validPayload(), SECRET);
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
// Both still decrypt correctly with the same secret.
|
|
||||||
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
|
|
||||||
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
// The whole point of the GCM upgrade: someone with only the cookie
|
|
||||||
// value should not be able to read the userId / role straight off
|
|
||||||
// it the way they could with the old base64-encoded JSON.
|
|
||||||
expect(cookie).not.toContain(validPayload().userId);
|
|
||||||
expect(cookie).not.toContain("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const [iv, ct] = cookie.split(".");
|
|
||||||
// Flip the last character of the ciphertext (still valid base64url).
|
|
||||||
const lastCh = ct!.slice(-1);
|
|
||||||
const replacement = lastCh === "A" ? "B" : "A";
|
|
||||||
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
|
|
||||||
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const otherIv = await signSession(validPayload(), SECRET);
|
|
||||||
const [, ct] = cookie.split(".");
|
|
||||||
const [otherIvB64] = otherIv.split(".");
|
|
||||||
const tampered = `${otherIvB64}.${ct}`;
|
|
||||||
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when verified with a different secret", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an expired cookie (exp <= now)", async () => {
|
|
||||||
const expired = { ...validPayload(), exp: NOW - 1 };
|
|
||||||
const cookie = await signSession(expired, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
|
|
||||||
const future = { ...validPayload(), iat: NOW + 120 };
|
|
||||||
const cookie = await signSession(future, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
|
|
||||||
const future = { ...validPayload(), iat: NOW + 30 };
|
|
||||||
const cookie = await signSession(future, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
|
|
||||||
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "2";
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie with an unknown role string", async () => {
|
|
||||||
const cookie = await signSession(
|
|
||||||
{ ...validPayload(), role: "superadmin" as never },
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie that doesn't have a '.' separator", async () => {
|
|
||||||
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
|
|
||||||
expect(await verifySession("", SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
|
|
||||||
// GCM requires a 12-byte nonce. Swap the IV portion for something
|
|
||||||
// that decodes to a different length and confirm we bounce it
|
|
||||||
// before handing weird input to crypto.subtle.decrypt.
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const [, ct] = cookie.split(".");
|
|
||||||
// 8 bytes encoded — too short.
|
|
||||||
const shortIv = "AAAAAAAAAAA";
|
|
||||||
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
|
|
||||||
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes COOKIE_NAME as 'session'", () => {
|
|
||||||
expect(COOKIE_NAME).toBe("session");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* Edge-runtime-safe AES-256-GCM session cookie. Runs in middleware
|
|
||||||
* and Server Actions. NO database, NO bcrypt, NO Node-only APIs —
|
|
||||||
* pure Web Crypto so it survives Edge runtime.
|
|
||||||
*
|
|
||||||
* Why GCM instead of HMAC-then-plaintext: GCM is authenticated
|
|
||||||
* encryption, so a leaked cookie no longer hands the userId/role to
|
|
||||||
* an attacker who only sees the bytes. Tampering with either the IV
|
|
||||||
* or the ciphertext invalidates the auth tag → decrypt throws → we
|
|
||||||
* return null. Replay protection comes from the per-payload `exp`
|
|
||||||
* field plus the global `OPERATOR_TOKEN_VERSION` kill switch.
|
|
||||||
*
|
|
||||||
* Cookie format: `<base64url(iv)>.<base64url(ciphertext+tag)>`
|
|
||||||
* - iv: 12 random bytes (GCM nonce)
|
|
||||||
* - ciphertext+tag: AES-GCM output (plaintext + 16-byte auth tag)
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const COOKIE_NAME = "session";
|
|
||||||
export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days
|
|
||||||
export const CLOCK_SKEW_SECONDS = 60;
|
|
||||||
|
|
||||||
export type Role = "admin" | "user";
|
|
||||||
|
|
||||||
export interface SessionPayload {
|
|
||||||
userId: string;
|
|
||||||
role: Role;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
v: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidPayload(x: unknown): x is SessionPayload {
|
|
||||||
if (typeof x !== "object" || x === null) return false;
|
|
||||||
const o = x as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof o.userId === "string" &&
|
|
||||||
(o.role === "admin" || o.role === "user") &&
|
|
||||||
typeof o.iat === "number" &&
|
|
||||||
typeof o.exp === "number" &&
|
|
||||||
typeof o.v === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64urlEncode(bytes: Uint8Array): string {
|
|
||||||
let s = "";
|
|
||||||
for (const b of bytes) s += String.fromCharCode(b);
|
|
||||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64urlDecode(str: string): Uint8Array {
|
|
||||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
|
||||||
const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
||||||
const out = new Uint8Array(s.length);
|
|
||||||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a 256-bit AES key from the operator-supplied AUTH_SECRET.
|
|
||||||
* SHA-256 hashes the secret to a fixed-length key so the secret can
|
|
||||||
* be any printable string in env (no min/max length policing here).
|
|
||||||
*/
|
|
||||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
|
||||||
const digest = await crypto.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(secret),
|
|
||||||
);
|
|
||||||
return crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
digest,
|
|
||||||
{ name: "AES-GCM" },
|
|
||||||
false,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signSession(
|
|
||||||
payload: SessionPayload,
|
|
||||||
secret: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const key = await deriveKey(secret);
|
|
||||||
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
||||||
const ct = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
||||||
key,
|
|
||||||
plaintext as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return `${b64urlEncode(iv)}.${b64urlEncode(ct)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifySession(
|
|
||||||
cookie: string,
|
|
||||||
secret: string,
|
|
||||||
now: number = Math.floor(Date.now() / 1000),
|
|
||||||
): Promise<SessionPayload | null> {
|
|
||||||
if (!cookie || typeof cookie !== "string") return null;
|
|
||||||
const dot = cookie.indexOf(".");
|
|
||||||
if (dot <= 0 || dot === cookie.length - 1) return null;
|
|
||||||
let iv: Uint8Array;
|
|
||||||
let ct: Uint8Array;
|
|
||||||
try {
|
|
||||||
iv = b64urlDecode(cookie.slice(0, dot));
|
|
||||||
ct = b64urlDecode(cookie.slice(dot + 1));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// GCM nonces are exactly 12 bytes. A wrong-sized IV would still
|
|
||||||
// sometimes succeed at the WebCrypto layer on some platforms;
|
|
||||||
// guard explicitly so callers can't slip a non-standard nonce past us.
|
|
||||||
if (iv.length !== 12) return null;
|
|
||||||
let plain: string;
|
|
||||||
try {
|
|
||||||
const key = await deriveKey(secret);
|
|
||||||
// The IV in `AesGcmParams` must be backed by a non-shared
|
|
||||||
// ArrayBuffer; TS 5.7+ tightened Uint8Array's generic to reject
|
|
||||||
// `SharedArrayBuffer`-backed views. b64urlDecode produces a
|
|
||||||
// regular ArrayBuffer, but we cast to BufferSource explicitly so
|
|
||||||
// future allocator changes don't regress this site.
|
|
||||||
const buf = await crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
||||||
key,
|
|
||||||
ct as BufferSource,
|
|
||||||
);
|
|
||||||
plain = new TextDecoder().decode(buf);
|
|
||||||
} catch {
|
|
||||||
// Auth-tag mismatch (tampered cookie or wrong key) lands here.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(plain);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!isValidPayload(parsed)) return null;
|
|
||||||
|
|
||||||
if (parsed.exp <= now) return null;
|
|
||||||
if (parsed.iat > now + CLOCK_SKEW_SECONDS) return null;
|
|
||||||
|
|
||||||
const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
|
|
||||||
if (parsed.v !== expectedV) return null;
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const cookiesGetMock = vi.fn();
|
|
||||||
const findUserMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
cookies: async () => ({ get: cookiesGetMock }),
|
|
||||||
}));
|
|
||||||
vi.mock("./db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: {
|
|
||||||
findFirst: (...a: unknown[]) => findUserMock(...a),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SECRET = "test-secret";
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
cookiesGetMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
import { signSession } from "./auth-cookie";
|
|
||||||
import { getCurrentUser, requireUser, requireAdmin } from "./auth";
|
|
||||||
|
|
||||||
const NOW_S = Math.floor(Date.now() / 1000);
|
|
||||||
const ADMIN = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
displayName: "Admin",
|
|
||||||
defaultTimezone: "UTC",
|
|
||||||
passwordHash: null,
|
|
||||||
};
|
|
||||||
const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const };
|
|
||||||
|
|
||||||
async function makeCookie(role: "admin" | "user"): Promise<string> {
|
|
||||||
return signSession(
|
|
||||||
{
|
|
||||||
userId: role === "admin" ? ADMIN.id : USER.id,
|
|
||||||
role,
|
|
||||||
iat: NOW_S,
|
|
||||||
exp: NOW_S + 3600,
|
|
||||||
v: 1,
|
|
||||||
},
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("auth helpers", () => {
|
|
||||||
it("getCurrentUser returns null when no cookie is set", async () => {
|
|
||||||
cookiesGetMock.mockReturnValue(undefined);
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
expect(u).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getCurrentUser returns the user row for a valid admin cookie", async () => {
|
|
||||||
const cookie = await makeCookie("admin");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
expect(u?.id).toBe(ADMIN.id);
|
|
||||||
expect(u?.role).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireUser throws when there is no session", async () => {
|
|
||||||
cookiesGetMock.mockReturnValue(undefined);
|
|
||||||
await expect(requireUser()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireAdmin throws when role is 'user'", async () => {
|
|
||||||
const cookie = await makeCookie("user");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
await expect(requireAdmin()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireAdmin returns the user when role is 'admin'", async () => {
|
|
||||||
const cookie = await makeCookie("admin");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const u = await requireAdmin();
|
|
||||||
expect(u.role).toBe("admin");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { db } from "./db";
|
|
||||||
import { COOKIE_NAME, verifySession } from "./auth-cookie";
|
|
||||||
|
|
||||||
export type AuthUser = {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
displayName: string;
|
|
||||||
defaultTimezone: string;
|
|
||||||
passwordHash: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class UnauthenticatedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Unauthenticated");
|
|
||||||
this.name = "UnauthenticatedError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class ForbiddenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Forbidden");
|
|
||||||
this.name = "ForbiddenError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the operator row whose userId is encoded in the session
|
|
||||||
* cookie, or null if the cookie is missing / invalid / the row is
|
|
||||||
* gone. Never throws — call requireUser() if you want a throw.
|
|
||||||
*/
|
|
||||||
export async function getCurrentUser(): Promise<AuthUser | null> {
|
|
||||||
const jar = await cookies();
|
|
||||||
const cookie = jar.get(COOKIE_NAME)?.value;
|
|
||||||
if (!cookie) return null;
|
|
||||||
const secret = process.env.AUTH_SECRET;
|
|
||||||
if (!secret) return null;
|
|
||||||
const payload = await verifySession(cookie, secret);
|
|
||||||
if (!payload) return null;
|
|
||||||
const row = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq }) => eq(o.id, payload.userId),
|
|
||||||
});
|
|
||||||
if (!row) return null;
|
|
||||||
if (row.role !== "admin" && row.role !== "user") return null;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
role: row.role,
|
|
||||||
displayName: row.displayName,
|
|
||||||
defaultTimezone: row.defaultTimezone,
|
|
||||||
passwordHash: row.passwordHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireUser(): Promise<AuthUser> {
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
if (!u) throw new UnauthenticatedError();
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdmin(): Promise<AuthUser> {
|
|
||||||
const u = await requireUser();
|
|
||||||
if (u.role !== "admin") throw new ForbiddenError();
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST
|
|
||||||
* order rows by createdAt ascending (with id as a deterministic
|
|
||||||
* tiebreaker) so the operator's earliest-added account stays on top.
|
|
||||||
*
|
|
||||||
* Earlier the orderBy used `asc(a.label)` which silently re-shuffled
|
|
||||||
* the list every time an account was renamed. This test pins the
|
|
||||||
* fix in source so a future refactor can't quietly bring the rename
|
|
||||||
* regression back.
|
|
||||||
*
|
|
||||||
* It's a static (regex) guard rather than an integration test
|
|
||||||
* because the live query needs Postgres + a seeded operator;
|
|
||||||
* pinning the source spelling keeps coverage cheap and CI-friendly.
|
|
||||||
*/
|
|
||||||
describe("listAccounts ordering (regression guard)", () => {
|
|
||||||
const src = readFileSync(
|
|
||||||
join(__dirname, "queries.ts"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
it("orders by created_at ASC", () => {
|
|
||||||
// Match across whitespace/comments inside listAccounts. Anchors:
|
|
||||||
// function header → orderBy → asc(a.createdAt).
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
expect(fnStart).toBeGreaterThan(-1);
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses id as a deterministic tiebreaker for ties on createdAt", () => {
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).toMatch(/asc\(a\.id\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT order by label (the regression we're guarding against)", () => {
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).not.toMatch(/asc\(a\.label\)/);
|
|
||||||
expect(fnBody).not.toMatch(/desc\(a\.label\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -5,10 +5,6 @@ import { db } from "./db";
|
|||||||
export type BotCommand =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
// Like account.unpair, but the bot also calls socket.logout() so
|
|
||||||
// WhatsApp drops this device from the operator's linked-devices
|
|
||||||
// list before the row is deleted.
|
|
||||||
| { type: "account.delete"; accountId: string }
|
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { getCurrentUser } from "./auth";
|
import { db } from "./db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility shim. The app used to seed a single operator and
|
* Returns the single seeded operator row. Since the app has no auth,
|
||||||
* attribute everything to it; now we have real auth + roles. Existing
|
* every action is attributed to this operator.
|
||||||
* call sites read `.id` and `.defaultTimezone` off the returned
|
|
||||||
* object — both are still present on the AuthUser shape, so the
|
|
||||||
* swap is mechanical and existing tests that mock @/lib/operator
|
|
||||||
* keep working unchanged.
|
|
||||||
*
|
|
||||||
* New code should call getCurrentUser / requireUser / requireAdmin
|
|
||||||
* from @/lib/auth directly.
|
|
||||||
*/
|
*/
|
||||||
export async function getSeededOperator() {
|
export async function getSeededOperator() {
|
||||||
const u = await getCurrentUser();
|
const op = await db.query.operators.findFirst({
|
||||||
if (!u) {
|
orderBy: (o, { asc }) => [asc(o.createdAt)],
|
||||||
throw new Error("Not authenticated");
|
});
|
||||||
|
if (!op) {
|
||||||
|
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
|
||||||
}
|
}
|
||||||
return u;
|
return op;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
validatePassword,
|
|
||||||
MIN_PASSWORD_LEN,
|
|
||||||
MAX_PASSWORD_LEN,
|
|
||||||
} from "./password-policy";
|
|
||||||
|
|
||||||
describe("validatePassword", () => {
|
|
||||||
it("accepts the canonical mixed-case + digit example", () => {
|
|
||||||
expect(validatePassword("hengs3rver").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the bare minimum length with a number", () => {
|
|
||||||
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
|
|
||||||
expect(validatePassword("abc12!").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts symbols in place of digits", () => {
|
|
||||||
expect(validatePassword("abcde!").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects passwords shorter than the minimum", () => {
|
|
||||||
const r = validatePassword("ab1!");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/at least 6/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects letters-only passwords", () => {
|
|
||||||
const r = validatePassword("abcdefgh");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects digits-only passwords", () => {
|
|
||||||
const r = validatePassword("12345678");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/letters/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects symbols-only passwords (no letters)", () => {
|
|
||||||
const r = validatePassword("!!!!!!!!");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
|
|
||||||
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
|
|
||||||
const r = validatePassword(tooLong);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/too long/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty input", () => {
|
|
||||||
expect(validatePassword("").ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-string input defensively", () => {
|
|
||||||
// Server actions are typed but a malformed FormData payload could land
|
|
||||||
// here as null/undefined; the validator must not throw.
|
|
||||||
// @ts-expect-error - defensive runtime guard
|
|
||||||
expect(validatePassword(null).ok).toBe(false);
|
|
||||||
// @ts-expect-error - defensive runtime guard
|
|
||||||
expect(validatePassword(undefined).ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the documented Facebook-aligned thresholds", () => {
|
|
||||||
expect(MIN_PASSWORD_LEN).toBe(6);
|
|
||||||
expect(MAX_PASSWORD_LEN).toBe(256);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Password policy modeled after Facebook's documented requirement
|
|
||||||
* (https://www.facebook.com/help/124904560921566): at least 6
|
|
||||||
* characters, with a recommended mix of letters and numbers/punctuation.
|
|
||||||
*
|
|
||||||
* We enforce the hard minimum (6) and the recommended-mix rule on
|
|
||||||
* password creation/reset (admin-only flows). Sign-in itself stays
|
|
||||||
* permissive — old short passwords keep working until they're reset —
|
|
||||||
* since rejecting them at login would lock people out without a recovery
|
|
||||||
* path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const MIN_PASSWORD_LEN = 6;
|
|
||||||
export const MAX_PASSWORD_LEN = 256;
|
|
||||||
|
|
||||||
export type PasswordCheck = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export function validatePassword(pw: string): PasswordCheck {
|
|
||||||
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (pw.length > MAX_PASSWORD_LEN) {
|
|
||||||
return { ok: false, error: "Password is too long." };
|
|
||||||
}
|
|
||||||
const hasLetter = /[A-Za-z]/.test(pw);
|
|
||||||
const hasNonLetter = /[^A-Za-z]/.test(pw);
|
|
||||||
if (!hasLetter || !hasNonLetter) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Password must mix letters with numbers or symbols.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
@ -6,18 +6,9 @@ export async function getDashboardStats(operatorId: string) {
|
|||||||
const accounts = await db.query.whatsappAccounts.findMany({
|
const accounts = await db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
});
|
});
|
||||||
// Reminders scoped to this operator's accounts. The previous
|
// All reminder rows so the dashboard can show active/total in one query.
|
||||||
// findMany() with no filter leaked global counts across users — a
|
// Status enum today is active / ended (paused will join in a later phase).
|
||||||
// brand-new user would see another operator's totals on the
|
const allReminders = await db.query.reminders.findMany();
|
||||||
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
|
|
||||||
// user's view isolated.
|
|
||||||
const reminderRows = await db.execute(sql`
|
|
||||||
SELECT r.id, r.status
|
|
||||||
FROM reminders r
|
|
||||||
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
|
||||||
WHERE wa.operator_id = ${operatorId}
|
|
||||||
`);
|
|
||||||
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
|
|
||||||
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
||||||
// ownership filter widens to: either the reminder still exists and the
|
// ownership filter widens to: either the reminder still exists and the
|
||||||
// operator owns its account, OR the reminder is gone but the run row
|
// operator owns its account, OR the reminder is gone but the run row
|
||||||
@ -63,12 +54,9 @@ export async function listAccounts(operatorId: string) {
|
|||||||
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||||
// by status produced phantom "I created an account but it's gone"
|
// by status produced phantom "I created an account but it's gone"
|
||||||
// bug reports.
|
// bug reports.
|
||||||
// Earliest-added on top, newest at the bottom. Stable across renames
|
|
||||||
// (a label edit shouldn't reorder the list and confuse muscle memory)
|
|
||||||
// and matches how other admin tools order accounts that grow over time.
|
|
||||||
return db.query.whatsappAccounts.findMany({
|
return db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
|
orderBy: (a, { asc }) => [asc(a.label)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,19 +70,11 @@ 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}
|
WHERE account_id = ${accountId} AND name % ${trimmed}
|
||||||
AND is_archived = false
|
|
||||||
AND name % ${trimmed}
|
|
||||||
ORDER BY similarity(name, ${trimmed}) DESC
|
ORDER BY similarity(name, ${trimmed}) DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`)
|
`)
|
||||||
@ -102,7 +82,6 @@ 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
|
||||||
`);
|
`);
|
||||||
@ -208,13 +187,11 @@ export async function listActivityRuns(
|
|||||||
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||||
// has been deleted but history was preserved) in the list.
|
// has been deleted but history was preserved) in the list.
|
||||||
// The `archived` flag flips the visibility filter:
|
// The `archived` flag flips the visibility filter:
|
||||||
// false (default) — non-archived, non-skipped rows (skipped runs
|
// false (default) — only non-archived rows
|
||||||
// belong to the Archived tab now)
|
// true — only archived rows (for the Archived tab)
|
||||||
// true — archived rows OR skipped rows (they're treated
|
|
||||||
// as "history" rather than active outcomes)
|
|
||||||
const archivedClause = opts.archived
|
const archivedClause = opts.archived
|
||||||
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
|
? sql`rr.archived_at IS NOT NULL`
|
||||||
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
|
: sql`rr.archived_at IS NULL`;
|
||||||
const rows = await db.execute(sql`
|
const rows = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
rr.id,
|
rr.id,
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { safeRedirect } from "./safe-redirect";
|
|
||||||
|
|
||||||
describe("safeRedirect", () => {
|
|
||||||
it("preserves a relative path that starts with a single slash", () => {
|
|
||||||
expect(safeRedirect("/dashboard")).toBe("/dashboard");
|
|
||||||
expect(safeRedirect("/reminders/new")).toBe("/reminders/new");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves query string and fragment", () => {
|
|
||||||
expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe(
|
|
||||||
"/legit?with=params&extra=fine#hash",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects protocol-relative URLs (//evil.com)", () => {
|
|
||||||
expect(safeRedirect("//evil.com")).toBe("/");
|
|
||||||
expect(safeRedirect("//evil.com/dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects absolute URLs", () => {
|
|
||||||
expect(safeRedirect("https://evil.com")).toBe("/");
|
|
||||||
expect(safeRedirect("http://evil.com/dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects javascript: and data: schemes", () => {
|
|
||||||
expect(safeRedirect("javascript:alert(1)")).toBe("/");
|
|
||||||
expect(safeRedirect("data:text/html,<script>x</script>")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to / for empty / null / undefined / whitespace input", () => {
|
|
||||||
expect(safeRedirect("")).toBe("/");
|
|
||||||
expect(safeRedirect(null)).toBe("/");
|
|
||||||
expect(safeRedirect(undefined)).toBe("/");
|
|
||||||
expect(safeRedirect(" ")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects paths that don't start with / (relative-relative)", () => {
|
|
||||||
expect(safeRedirect("dashboard")).toBe("/");
|
|
||||||
expect(safeRedirect("./dashboard")).toBe("/");
|
|
||||||
expect(safeRedirect("../dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Returns `next` if it is a safe relative path, otherwise "/".
|
|
||||||
*
|
|
||||||
* Safe means: starts with a single forward slash AND not "//" (which
|
|
||||||
* is a protocol-relative URL, e.g. //evil.com). Anything else falls
|
|
||||||
* back to the root — including empty input, absolute URLs, javascript:
|
|
||||||
* URIs, and relative-relative paths like "dashboard" or "../foo".
|
|
||||||
*/
|
|
||||||
export function safeRedirect(next: string | null | undefined): string {
|
|
||||||
if (typeof next !== "string") return "/";
|
|
||||||
const s = next.trim();
|
|
||||||
if (s.length < 2) return "/";
|
|
||||||
if (!s.startsWith("/")) return "/";
|
|
||||||
if (s.startsWith("//")) return "/";
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
const SECRET = "test-secret";
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
import { signSession } from "./lib/auth-cookie";
|
|
||||||
import { middleware } from "./middleware";
|
|
||||||
|
|
||||||
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
|
|
||||||
const url = new URL(`https://wabot.04080616.xyz${path}`);
|
|
||||||
const headers = new Headers();
|
|
||||||
if (cookie) headers.set("cookie", `session=${cookie}`);
|
|
||||||
return new NextRequest(url, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validCookie(): Promise<string> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return signSession(
|
|
||||||
{
|
|
||||||
userId: "00000000-0000-0000-0000-000000000000",
|
|
||||||
role: "admin",
|
|
||||||
iat: now,
|
|
||||||
exp: now + 3600,
|
|
||||||
v: 1,
|
|
||||||
},
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("middleware", () => {
|
|
||||||
it("page request without a cookie redirects to /login?next=…", async () => {
|
|
||||||
const r = await middleware(await makeReq("/dashboard"));
|
|
||||||
expect(r.status).toBe(307);
|
|
||||||
expect(r.headers.get("location")).toContain("/login");
|
|
||||||
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/api/* request without a cookie returns 401 with no body", async () => {
|
|
||||||
const r = await middleware(await makeReq("/api/events"));
|
|
||||||
expect(r.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("page request with a valid cookie passes through", async () => {
|
|
||||||
const r = await middleware(await makeReq("/dashboard", await validCookie()));
|
|
||||||
// NextResponse.next() returns a 200 with the x-middleware-next header.
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("page request with a tampered cookie redirects to /login", async () => {
|
|
||||||
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
|
|
||||||
const r = await middleware(await makeReq("/dashboard", cookie));
|
|
||||||
expect(r.status).toBe(307);
|
|
||||||
expect(r.headers.get("location")).toContain("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
|
|
||||||
for (const path of [
|
|
||||||
"/login",
|
|
||||||
"/logout",
|
|
||||||
"/api/health",
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
"/icon-192.png",
|
|
||||||
"/favicon.ico",
|
|
||||||
]) {
|
|
||||||
const r = await middleware(await makeReq(path));
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
|
|
||||||
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await middleware(
|
|
||||||
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
|
|
||||||
)
|
|
||||||
).status,
|
|
||||||
).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,41 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set<string>([
|
export function middleware(req: NextRequest) {
|
||||||
"/login",
|
|
||||||
"/logout",
|
|
||||||
"/api/health",
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
"/favicon.ico",
|
|
||||||
"/robots.txt",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isPublic(path: string): boolean {
|
|
||||||
if (PUBLIC_PATHS.has(path)) return true;
|
|
||||||
if (path.startsWith("/icon-")) return true;
|
|
||||||
if (path.startsWith("/_next/")) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
if (isPublic(path)) return NextResponse.next();
|
|
||||||
|
|
||||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
// Block all /api/* except a small set of read-only endpoints.
|
||||||
const secret = process.env.AUTH_SECRET;
|
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||||
const ok =
|
const allowed =
|
||||||
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
path === "/api/events" ||
|
||||||
if (ok) return NextResponse.next();
|
path === "/api/health" ||
|
||||||
|
path.startsWith("/api/qr/");
|
||||||
if (path.startsWith("/api/")) {
|
if (path.startsWith("/api/") && !allowed) {
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
const url = req.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
return NextResponse.next();
|
||||||
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import {
|
|
||||||
assertJournalMonotonic,
|
|
||||||
formatJournalViolations,
|
|
||||||
type JournalEntry,
|
|
||||||
} from "@cmbot/db/journal-check";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CI guard against the recurring drizzle journal-skip bug.
|
|
||||||
*
|
|
||||||
* Drizzle's migrator orders entries by `when` (not `idx`) and only
|
|
||||||
* applies entries whose `when` is greater than the latest applied
|
|
||||||
* row's recorded `created_at`. We've shipped two breaking deploys
|
|
||||||
* (0010/0011 and 0012/0013) where freshly-generated migrations had
|
|
||||||
* `when` values older than a prior manually-bumped entry — `pnpm
|
|
||||||
* migrate` printed "Migrations applied." while silently skipping
|
|
||||||
* the new SQL, and production 500'd until we hand-fixed the journal.
|
|
||||||
*
|
|
||||||
* This test reads the committed _journal.json and fails if the
|
|
||||||
* entries aren't strictly monotonically increasing by `when` in the
|
|
||||||
* same order as `idx`. Catches a bad commit at PR time instead of
|
|
||||||
* at the next deploy.
|
|
||||||
*/
|
|
||||||
describe("drizzle journal monotonicity (regression guard)", () => {
|
|
||||||
const journalPath = join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"packages",
|
|
||||||
"db",
|
|
||||||
"migrations",
|
|
||||||
"meta",
|
|
||||||
"_journal.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
|
||||||
entries: JournalEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
it("loads at least one journal entry (sanity)", () => {
|
|
||||||
expect(raw.entries.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("`when` timestamps are strictly increasing in `idx` order", () => {
|
|
||||||
const result = assertJournalMonotonic(raw.entries);
|
|
||||||
if (!result.ok) {
|
|
||||||
// Print the same actionable message migrate.ts prints, so a
|
|
||||||
// failed CI run reads exactly like a failed local migrate.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(formatJournalViolations(result));
|
|
||||||
}
|
|
||||||
expect(result.violations).toEqual([]);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import { join, relative } from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static guard: no production `.tsx` file may pass `showCloseButton`
|
|
||||||
* to `<DialogFooter>`.
|
|
||||||
*
|
|
||||||
* Why: the shared DialogFooter renders an EXTRA outline-styled
|
|
||||||
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
|
|
||||||
* we have that already provides its own primary action also includes
|
|
||||||
* a Cancel/dismiss button (either via DialogClose or by closing the
|
|
||||||
* Dialog state on submit) — and Radix's auto-rendered corner X
|
|
||||||
* already gives users a third way out. The redundant Close button
|
|
||||||
* cluttered the footer and shipped to production multiple times
|
|
||||||
* before this guard existed; this test stops it from regressing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SRC_ROOT = join(__dirname, "..");
|
|
||||||
|
|
||||||
function listTsxFiles(dir: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
const st = statSync(full);
|
|
||||||
if (st.isDirectory()) {
|
|
||||||
out.push(...listTsxFiles(full));
|
|
||||||
} else if (entry.endsWith(".tsx")) {
|
|
||||||
out.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Hit {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
excerpt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHits(content: string): Array<{ line: number; excerpt: string }> {
|
|
||||||
const hits: Array<{ line: number; excerpt: string }> = [];
|
|
||||||
// Match `<DialogFooter` with `showCloseButton` somewhere in the
|
|
||||||
// opening tag. Stops at `>` so we don't accidentally cross into the
|
|
||||||
// children. Multi-line opening tags are handled by `[\s\S]`.
|
|
||||||
const matches = content.matchAll(
|
|
||||||
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
|
|
||||||
);
|
|
||||||
for (const m of matches) {
|
|
||||||
const idx = m.index ?? 0;
|
|
||||||
const line = content.slice(0, idx).split("\n").length;
|
|
||||||
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("static guard: no <DialogFooter showCloseButton>", () => {
|
|
||||||
// Skip this test file (it intentionally contains the pattern strings)
|
|
||||||
// and all other .test.tsx files (they're examples, not production UI).
|
|
||||||
const files = listTsxFiles(SRC_ROOT).filter(
|
|
||||||
(f) => !/\.test\.tsx?$/.test(f),
|
|
||||||
);
|
|
||||||
|
|
||||||
it("scans at least one source file (sanity)", () => {
|
|
||||||
expect(files.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
|
|
||||||
const allHits: Hit[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const content = readFileSync(file, "utf8");
|
|
||||||
for (const h of findHits(content)) {
|
|
||||||
allHits.push({ file: relative(SRC_ROOT, file), ...h });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allHits.length > 0) {
|
|
||||||
const message = allHits
|
|
||||||
.map((h) => ` ${h.file}:${h.line} → ${h.excerpt}`)
|
|
||||||
.join("\n");
|
|
||||||
throw new Error(
|
|
||||||
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
|
|
||||||
`The DialogFooter component injects an extra "Close" button when this prop\n` +
|
|
||||||
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
|
|
||||||
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(allHits).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findHits parser", () => {
|
|
||||||
it("matches a single-line <DialogFooter showCloseButton>", () => {
|
|
||||||
expect(
|
|
||||||
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("matches when other props are present alongside showCloseButton", () => {
|
|
||||||
expect(
|
|
||||||
findHits('<DialogFooter className="x" showCloseButton>'),
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("matches across multiple lines", () => {
|
|
||||||
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
|
|
||||||
expect(findHits(src)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("does NOT match a clean <DialogFooter>", () => {
|
|
||||||
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it("does NOT match a similarly-named prop on an unrelated component", () => {
|
|
||||||
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -19,7 +19,7 @@ services:
|
|||||||
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||||
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
||||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||||
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
|
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
|
||||||
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
@ -36,8 +36,6 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
|
||||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,5 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
|
||||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- tools
|
- tools
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
# 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,13 +26,5 @@ COPY --from=build /app/node_modules /app/node_modules
|
|||||||
COPY --from=build /app/apps/bot /app/apps/bot
|
COPY --from=build /app/apps/bot /app/apps/bot
|
||||||
COPY --from=build /app/packages/db /app/packages/db
|
COPY --from=build /app/packages/db /app/packages/db
|
||||||
COPY --from=build /app/packages/shared /app/packages/shared
|
COPY --from=build /app/packages/shared /app/packages/shared
|
||||||
# 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,20 +18,7 @@ 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
|
||||||
# Placeholder env values during `next build`'s "Collecting page data"
|
RUN pnpm --filter @cmbot/shared build && \
|
||||||
# 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
|
||||||
|
|
||||||
@ -42,21 +29,5 @@ ENV HOSTNAME=0.0.0.0
|
|||||||
COPY --from=build /app/apps/web/.next/standalone ./
|
COPY --from=build /app/apps/web/.next/standalone ./
|
||||||
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
# 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"]
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
# 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
200
docs/runbook.md
@ -1,200 +0,0 @@
|
|||||||
# 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.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,437 +0,0 @@
|
|||||||
# Auth + Production Hardening Design
|
|
||||||
|
|
||||||
> Spec for closing the production-readiness gap before promoting the
|
|
||||||
> bot to public-internet exposure at `wabot.04080616.xyz`. Covers the
|
|
||||||
> session-cookie auth model with username + password + role, plus the
|
|
||||||
> hygiene work that has to land alongside it (robots, env, container
|
|
||||||
> non-root) so the public surface is safe in one change.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add operator authentication to the web app so the public URL stops
|
|
||||||
being a foothold for anyone who finds it, and at the same time close
|
|
||||||
the highest-risk production gaps surfaced in the v1.1.0 audit:
|
|
||||||
indexable content, committed credentials, root-running containers,
|
|
||||||
and four un-rate-limited Server Actions.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Single-host self-hosted deployment, public-internet via reverse
|
|
||||||
proxy + TLS at `wabot.04080616.xyz`.
|
|
||||||
- Up to a handful of users today, with room to grow. One must be
|
|
||||||
`admin`; the rest are `user`.
|
|
||||||
- Mobile PWA homescreen workflow: 30-day cookie, no friction at
|
|
||||||
re-open, no third-party identity provider.
|
|
||||||
- No new infra dependencies. Postgres + Docker compose stay the
|
|
||||||
whole platform. No NextAuth / Auth.js, no external KV, no SMS.
|
|
||||||
- Existing call sites must be cleanly retrofitted without breaking
|
|
||||||
the 66 call sites that currently use `getSeededOperator()`.
|
|
||||||
- All code changes covered by unit tests; no test relies on a live
|
|
||||||
Postgres or browser.
|
|
||||||
|
|
||||||
## Approach: roll-our-own session cookie
|
|
||||||
|
|
||||||
A library would be heavy for one role gate and one cookie. We pick
|
|
||||||
up `bcrypt` for password hashing (battle-tested) and Web Crypto's
|
|
||||||
HMAC for cookie signing (stdlib, edge-runtime compatible). All other
|
|
||||||
code is domain-owned and exhaustively tested.
|
|
||||||
|
|
||||||
The model: the user posts username + password to a Server Action,
|
|
||||||
the action verifies against a per-user `password_hash` row, and the
|
|
||||||
response sets a signed cookie carrying `{ userId, role, iat, exp, v }`.
|
|
||||||
Middleware verifies the cookie on every request; Server Actions
|
|
||||||
double-check via `requireUser()` / `requireAdmin()` so a forgotten
|
|
||||||
middleware path can't bypass the gate.
|
|
||||||
|
|
||||||
## Schema migration (`0010_add_user_auth.sql`)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE operators
|
|
||||||
ADD COLUMN username text,
|
|
||||||
ADD COLUMN password_hash text;
|
|
||||||
CREATE UNIQUE INDEX operators_username_uq
|
|
||||||
ON operators (lower(username));
|
|
||||||
-- Backfill the seed row so it has a username; password_hash stays NULL
|
|
||||||
-- so the operator is forced to set one via the CLI before they can sign
|
|
||||||
-- in. Sets a clear "you have to do this before going live" gate.
|
|
||||||
UPDATE operators
|
|
||||||
SET username = 'admin'
|
|
||||||
WHERE username IS NULL;
|
|
||||||
ALTER TABLE operators
|
|
||||||
ALTER COLUMN username SET NOT NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
`telegramUserId` stays for now (it's referenced from existing migrations
|
|
||||||
and seed flow) but no longer drives auth. `defaultTimezone` and `role`
|
|
||||||
are unchanged. `operators.role` already defaults to `"admin"`.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
|
|
||||||
Two values, no enum constraint at the DB layer (text — same as
|
|
||||||
existing).
|
|
||||||
|
|
||||||
| role | can do |
|
|
||||||
| ----- | ------------------------------------------------------------- |
|
|
||||||
| admin | everything in the app + user management (CRUD other users) |
|
|
||||||
| user | everything except `/settings/users` and the user-mgmt actions |
|
|
||||||
|
|
||||||
A third "viewer" role isn't worth it today; can be added later by
|
|
||||||
extending the role check.
|
|
||||||
|
|
||||||
## Cookie format
|
|
||||||
|
|
||||||
Header value: `session=<base64url(payload)>.<base64url(hmac)>`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type SessionPayload = {
|
|
||||||
userId: string; // operators.id (uuid)
|
|
||||||
role: "admin" | "user";
|
|
||||||
iat: number; // issued-at, unix seconds
|
|
||||||
exp: number; // expires-at, unix seconds (iat + 30 days)
|
|
||||||
v: number; // OPERATOR_TOKEN_VERSION at issue time
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
HMAC is HMAC-SHA256 over the base64url-encoded payload string with
|
|
||||||
`AUTH_SECRET` as the key. Verification rejects on:
|
|
||||||
|
|
||||||
- Bad shape (no `.`, base64 decode fails, JSON parse fails).
|
|
||||||
- HMAC mismatch (uses constant-time compare).
|
|
||||||
- `exp <= now`.
|
|
||||||
- `iat > now + 60` (clock-skew guard, 60s tolerance).
|
|
||||||
- `v !== process.env.OPERATOR_TOKEN_VERSION` (defaults to `"1"`).
|
|
||||||
- `role` not one of `"admin"` / `"user"`.
|
|
||||||
|
|
||||||
Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`.
|
|
||||||
`Max-Age=0` on logout to clear.
|
|
||||||
|
|
||||||
`OPERATOR_TOKEN_VERSION` env var (default `"1"`) is the global
|
|
||||||
session-invalidation lever. Bumping it on the host instantly logs out
|
|
||||||
every user — no DB writes — useful after a host compromise or a
|
|
||||||
known-shared password.
|
|
||||||
|
|
||||||
## Login flow
|
|
||||||
|
|
||||||
Page: `apps/web/src/app/login/page.tsx`. Single form with:
|
|
||||||
|
|
||||||
- Username input (`type=text`, autocomplete `username`)
|
|
||||||
- Password input (`type=password`, autocomplete `current-password`)
|
|
||||||
- Submit button "Sign in"
|
|
||||||
- Error slot for the generic message
|
|
||||||
- A small note: "First time? Run `./scripts/set-password.sh <username>`
|
|
||||||
in your tools container to set a password."
|
|
||||||
|
|
||||||
Server action `loginAction(formData: FormData)`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. Read username, password from FormData.
|
|
||||||
2. Reject if either >256 chars (DoS guard, no bcrypt).
|
|
||||||
3. Reject if either empty.
|
|
||||||
4. Apply rate limit: checkRateLimit("login:" + ip, { max: 10, windowSec: 300 }).
|
|
||||||
On exhaustion → return { ok: false, error: "Too many attempts, try later." }
|
|
||||||
5. Look up user: select * from operators where lower(username)=lower($1)
|
|
||||||
6. If user not found OR user.password_hash IS NULL:
|
|
||||||
await bcrypt.compare(password, DUMMY_HASH); // timing equivalence
|
|
||||||
return { ok: false, error: "Invalid username or password." }
|
|
||||||
7. await bcrypt.compare(password, user.password_hash)
|
|
||||||
if false: return { ok: false, error: "Invalid username or password." }
|
|
||||||
8. Issue cookie: signSession({ userId, role, iat: now, exp: now + 30d, v: TOKEN_VERSION })
|
|
||||||
9. Redirect to safe(next) ?? "/"
|
|
||||||
```
|
|
||||||
|
|
||||||
`safe(next)`: must be a string starting with `/` AND not starting
|
|
||||||
with `//`. Otherwise return `null`.
|
|
||||||
|
|
||||||
Logout action `logoutAction()`: clear the cookie via
|
|
||||||
`cookies().set("session", "", { maxAge: 0, ... })` and redirect to
|
|
||||||
`/login`.
|
|
||||||
|
|
||||||
## Middleware gate
|
|
||||||
|
|
||||||
`apps/web/src/middleware.ts` extends the existing API allowlist with
|
|
||||||
the auth check.
|
|
||||||
|
|
||||||
```text
|
|
||||||
For every request:
|
|
||||||
- If path is in allowlist (auth-free):
|
|
||||||
/login, /logout, /api/health, /manifest.webmanifest,
|
|
||||||
/icon-*, /favicon.ico, /_next/static/*, /_next/image
|
|
||||||
→ NextResponse.next()
|
|
||||||
- Read session cookie. Verify (HMAC, exp, iat-skew, version, role shape).
|
|
||||||
- On valid: NextResponse.next()
|
|
||||||
- On invalid + path starts with /api/: 401, no body
|
|
||||||
- On invalid + page request: 302 to /login?next=<encoded path>
|
|
||||||
```
|
|
||||||
|
|
||||||
`/api/events` and `/api/qr/[accountId]` are explicitly removed from
|
|
||||||
the unauth allowlist — middleware now requires a session for them.
|
|
||||||
|
|
||||||
The middleware imports the verifier from `@/lib/auth-cookie` (a
|
|
||||||
dependency-free module that runs on the edge runtime — no bcrypt,
|
|
||||||
no DB).
|
|
||||||
|
|
||||||
## Server-action defense-in-depth
|
|
||||||
|
|
||||||
`apps/web/src/lib/auth.ts` (Node runtime — DB access OK):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export async function getCurrentUser(): Promise<User | null>
|
|
||||||
export async function requireUser(): Promise<User> // throws Response 401 / redirects
|
|
||||||
export async function requireAdmin(): Promise<User> // requireUser + role === "admin"
|
|
||||||
```
|
|
||||||
|
|
||||||
`getSeededOperator()` is renamed to `getCurrentUser()` (and rewired
|
|
||||||
to read the verified cookie + look up the user). All 66 call sites
|
|
||||||
swap mechanically. Existing typing stays compatible because the
|
|
||||||
returned shape is a superset.
|
|
||||||
|
|
||||||
Every Server Action begins with `await requireUser()` (or
|
|
||||||
`requireAdmin()` for admin-only). This is the second layer; the
|
|
||||||
middleware is the first. Both must agree before any state mutates.
|
|
||||||
|
|
||||||
## User management surface
|
|
||||||
|
|
||||||
Admin-only, gated by `requireAdmin()` at every entry point.
|
|
||||||
|
|
||||||
- `/settings/users` (page) — list of users with role chip + createdAt;
|
|
||||||
inline "Reset password", "Demote/Promote", "Delete" buttons. New
|
|
||||||
user form at top.
|
|
||||||
- `createUserAction({ username, password, role })` — validate inputs,
|
|
||||||
bcrypt the password, insert.
|
|
||||||
- `setUserRoleAction({ userId, role })` — guard: if `userId === self.id`
|
|
||||||
AND `role !== "admin"`, refuse with "you can't demote yourself".
|
|
||||||
- `resetUserPasswordAction({ userId, newPassword })` — bcrypt + update.
|
|
||||||
Does NOT change cookies — the affected user keeps their existing
|
|
||||||
session until expiry or a token-version bump.
|
|
||||||
- `deleteUserAction({ userId })` — guard: refuse self-delete.
|
|
||||||
Additional guard: if deleting the last admin, refuse with "promote
|
|
||||||
another user to admin first".
|
|
||||||
|
|
||||||
All admin actions fan out a refresh of `/settings/users` via
|
|
||||||
`revalidatePath`.
|
|
||||||
|
|
||||||
## CLI bootstrap
|
|
||||||
|
|
||||||
The actual hashing happens in a small TSX script (so it can `import
|
|
||||||
bcrypt` from the workspace), wrapped by a one-line bash launcher
|
|
||||||
that runs it through the `tools` container. Two pieces:
|
|
||||||
|
|
||||||
`packages/db/src/scripts/set-password.ts` — reads `username` from
|
|
||||||
argv, prompts for password on stdin (echo off via `readline`'s
|
|
||||||
`writeMask`), bcrypts at 12 rounds, runs an `UPDATE operators SET
|
|
||||||
password_hash = $1 WHERE lower(username) = lower($2)`, exits
|
|
||||||
non-zero if no rows matched.
|
|
||||||
|
|
||||||
`packages/db/src/scripts/create-user.ts` — same pattern, but
|
|
||||||
INSERTs a fresh row with `username`, `role`, `password_hash`,
|
|
||||||
default timezone, and a synthetic `telegramUserId` (current time-
|
|
||||||
millis) since the column is still NOT NULL until a future cleanup
|
|
||||||
migration.
|
|
||||||
|
|
||||||
`scripts/set-password.sh` and `scripts/create-user.sh` — thin
|
|
||||||
wrappers that invoke the TSX scripts via `pnpm --filter @cmbot/db
|
|
||||||
exec tsx ...` inside the tools container, matching the existing
|
|
||||||
script-runner pattern.
|
|
||||||
|
|
||||||
Used to bootstrap the first admin and to recover when an admin
|
|
||||||
loses their password. After bootstrap, all user management happens
|
|
||||||
through the web UI.
|
|
||||||
|
|
||||||
## Rate limits added
|
|
||||||
|
|
||||||
| action | limit |
|
|
||||||
| ---------------------------- | -------------------------------- |
|
|
||||||
| loginAction | 10 / 5 min per IP |
|
|
||||||
| sendTestAction | 3 / 60 s per groupId |
|
|
||||||
| resumeReminderRunAction | 30 / 10 s per IP (existing infra)|
|
|
||||||
| cancelReminderRunAction | 30 / 10 s per IP |
|
|
||||||
| createUserAction | 5 / 60 s per IP |
|
|
||||||
| resetUserPasswordAction | 5 / 60 s per IP |
|
|
||||||
|
|
||||||
`checkRateLimit` is the existing Postgres-backed helper.
|
|
||||||
|
|
||||||
## Robots / noindex
|
|
||||||
|
|
||||||
`apps/web/src/app/robots.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { MetadataRoute } from "next";
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus `metadata.robots = { index: false, follow: false }` in the root
|
|
||||||
`apps/web/src/app/layout.tsx`. Two layers — robots.txt is advisory,
|
|
||||||
the meta is authoritative.
|
|
||||||
|
|
||||||
## Env hygiene
|
|
||||||
|
|
||||||
- Add `.env*` to `.gitignore` (already excludes `.env.local`,
|
|
||||||
`.env.*.local` — this widens to all `.env*` outside `.env.example`).
|
|
||||||
- `git rm --cached .env.development` and recreate locally without
|
|
||||||
committing.
|
|
||||||
- New `.env.example` documents every required key with placeholder
|
|
||||||
values, including the new `OPERATOR_TOKEN_VERSION`.
|
|
||||||
- After this change ships, the operator rotates the leaked
|
|
||||||
`AUTH_SECRET` and Postgres password (manual step, called out in
|
|
||||||
the upgrade notes).
|
|
||||||
|
|
||||||
## Container hardening
|
|
||||||
|
|
||||||
Both Dockerfiles:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
RUN useradd -m -u 1000 -s /usr/sbin/nologin app && \
|
|
||||||
mkdir -p /data/sessions /data/media && \
|
|
||||||
chown -R app:app /app /data && \
|
|
||||||
chmod 700 /data/sessions
|
|
||||||
USER app
|
|
||||||
```
|
|
||||||
|
|
||||||
The `dev-data:/data` volume mount in `docker-compose.dev.yml` keeps
|
|
||||||
working since the host UID matches the in-container `app` UID 1000.
|
|
||||||
|
|
||||||
## Origin allowlist
|
|
||||||
|
|
||||||
`next.config.ts` adds:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
experimental: {
|
|
||||||
serverActions: {
|
|
||||||
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Same-origin Server Action posts already work; this guards against
|
|
||||||
cross-origin POSTs from another domain attempting to invoke an
|
|
||||||
action via a known cookie.
|
|
||||||
|
|
||||||
## Test plan (38 tests)
|
|
||||||
|
|
||||||
### `auth-cookie.test.ts` — pure HMAC + verification logic
|
|
||||||
|
|
||||||
1. `signSession` then `verifySession` round-trips.
|
|
||||||
2. Tampered payload → verify rejects.
|
|
||||||
3. Tampered signature → verify rejects.
|
|
||||||
4. Wrong secret → verify rejects.
|
|
||||||
5. Constant-time compare prevents char-by-char timing leak (assert
|
|
||||||
`crypto.timingSafeEqual` is used).
|
|
||||||
6. Cookie expired (`exp <= now`) → reject.
|
|
||||||
7. Cookie issued in the future (`iat > now + 60`) → reject (clock-skew).
|
|
||||||
8. Cookie with stale `v` (TOKEN_VERSION bumped after issue) → reject.
|
|
||||||
9. Cookie with bad `role` value (`"superadmin"`) → reject.
|
|
||||||
10. Cookie missing fields → reject.
|
|
||||||
|
|
||||||
### `login-action.test.ts` — login flow
|
|
||||||
|
|
||||||
11. Valid credentials → cookie issued with right shape.
|
|
||||||
12. Wrong password → no cookie, generic error.
|
|
||||||
13. Wrong username → no cookie, generic error, dummy-bcrypt called
|
|
||||||
(timing equivalence).
|
|
||||||
14. `password_hash IS NULL` user → "set password via CLI" error.
|
|
||||||
15. Empty username or password → 400-equivalent (no DB hit).
|
|
||||||
16. Username/password >256 chars → rejected before bcrypt.
|
|
||||||
17. Username case-insensitive (`Admin` matches `admin`).
|
|
||||||
18. 11th login attempt within window → 429 (rate-limited).
|
|
||||||
19. After window expiry, attempts succeed.
|
|
||||||
20. Failed login logs warning with username + IP, no password.
|
|
||||||
21. Cookie sets correct attrs (HttpOnly, Secure, SameSite, Path,
|
|
||||||
Max-Age).
|
|
||||||
|
|
||||||
### `middleware.test.ts` — gate behavior
|
|
||||||
|
|
||||||
22. No cookie + page request → 302 to `/login?next=<path>`.
|
|
||||||
23. No cookie + `/api/...` (non-allowlisted) → 401.
|
|
||||||
24. Valid cookie + page → next().
|
|
||||||
25. Tampered cookie → 302 to `/login`.
|
|
||||||
26. Allowlisted (`/login`, `/api/health`, manifest, icons) bypasses.
|
|
||||||
27. `/api/events` and `/api/qr/[id]` are NOT in allowlist (regression
|
|
||||||
against the audit's Critical findings).
|
|
||||||
|
|
||||||
### `next-param.test.ts` — open-redirect prevention
|
|
||||||
|
|
||||||
28. `/dashboard` → preserved.
|
|
||||||
29. `//evil.com` → falls back to `/`.
|
|
||||||
30. `https://evil.com` → falls back to `/`.
|
|
||||||
31. `javascript:alert(1)` → falls back to `/`.
|
|
||||||
32. `/path?with=query&extra=fine` → preserved verbatim.
|
|
||||||
|
|
||||||
### `require-helpers.test.ts` — Server-action gates
|
|
||||||
|
|
||||||
33. `requireUser()` throws with no session.
|
|
||||||
34. `requireUser()` returns the user with valid session.
|
|
||||||
35. `requireAdmin()` throws when role === "user".
|
|
||||||
36. `requireAdmin()` returns the user when role === "admin".
|
|
||||||
|
|
||||||
### `user-management.test.ts` — admin guards
|
|
||||||
|
|
||||||
37. Self-demote (`setUserRoleAction({ userId: self, role: "user" })`)
|
|
||||||
→ ok:false with clear error.
|
|
||||||
38. Last-admin delete (deleting only admin user) → ok:false with
|
|
||||||
"promote another user first".
|
|
||||||
|
|
||||||
## Migration risk
|
|
||||||
|
|
||||||
`getSeededOperator()` is the one big touch. The 66 call sites are
|
|
||||||
mostly Server Actions and queries that read `.id` and
|
|
||||||
`.defaultTimezone` off the returned object — the new shape is a
|
|
||||||
superset, so the change is mechanical.
|
|
||||||
|
|
||||||
To keep churn off the existing test suite (~12 tests mock
|
|
||||||
`@/lib/operator`), `apps/web/src/lib/operator.ts` keeps its export
|
|
||||||
but reimplements `getSeededOperator` as a thin pass-through to
|
|
||||||
`getCurrentUser` from `@/lib/auth`. Existing mocks that target
|
|
||||||
`@/lib/operator` keep working unchanged. New code uses
|
|
||||||
`getCurrentUser` / `requireUser` / `requireAdmin` directly; the old
|
|
||||||
name is kept as a compatibility shim and removed in a follow-up
|
|
||||||
once all sites are swept.
|
|
||||||
|
|
||||||
A `DUMMY_HASH` constant lives at the top of the login action — it's
|
|
||||||
a precomputed bcrypt hash of a known throwaway string (`"x"`),
|
|
||||||
generated once at build time and committed. We compare against it
|
|
||||||
on the user-not-found path so timing is identical to the wrong-
|
|
||||||
password path. Generating a fresh dummy hash per request would
|
|
||||||
double the bcrypt work and create its own timing signal.
|
|
||||||
|
|
||||||
## Out of scope (deferred)
|
|
||||||
|
|
||||||
- WebAuthn / passkeys.
|
|
||||||
- 2FA / TOTP.
|
|
||||||
- Email-based password recovery (operator restarts container with
|
|
||||||
a new env var `OPERATOR_TOKEN_VERSION` if all admins lose their
|
|
||||||
passwords; CLI helps the rest).
|
|
||||||
- Account lockout (rate limit is enough for one operator's threat
|
|
||||||
model).
|
|
||||||
- SSO / OAuth providers.
|
|
||||||
- Audit-log surface for "who logged in when". The pino warn line
|
|
||||||
is the minimum; a structured audit table is later work.
|
|
||||||
- A "remember this device" feature distinct from the 30-day cookie.
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
|
|
||||||
- The bot can be exposed at `wabot.04080616.xyz` and any
|
|
||||||
unauthenticated request to a non-allowlisted path returns 401
|
|
||||||
(API) or redirects to `/login` (page).
|
|
||||||
- A correct username + password issues a 30-day cookie that survives
|
|
||||||
reload, browser restart, and PWA homescreen launches.
|
|
||||||
- A wrong username, a wrong password, and a missing-password user
|
|
||||||
all produce the same generic "Invalid username or password"
|
|
||||||
error and the same wall-clock duration (timing-equivalent).
|
|
||||||
- Bumping `OPERATOR_TOKEN_VERSION` on the host invalidates every
|
|
||||||
active session immediately.
|
|
||||||
- An attacker tampering with the cookie payload, signature, or
|
|
||||||
issued-at can't pass middleware.
|
|
||||||
- Eleven login attempts from the same IP within five minutes
|
|
||||||
produce a 429 on the eleventh.
|
|
||||||
- A `user`-role session can browse, schedule, and resume reminders
|
|
||||||
but cannot reach `/settings/users`.
|
|
||||||
- An admin can't demote or delete their own row, and can't delete
|
|
||||||
the last admin.
|
|
||||||
- `robots.txt` returns `Disallow: /` and the rendered HTML carries
|
|
||||||
`<meta name="robots" content="noindex, nofollow">`.
|
|
||||||
- Both containers run as UID 1000, sessions dir is `chmod 700`.
|
|
||||||
- `.env.development` is gone from the repo and `.gitignore` excludes
|
|
||||||
every `.env*` except `.env.example`.
|
|
||||||
- All 38 tests in the plan pass; existing 471 tests still pass.
|
|
||||||
@ -10,41 +10,12 @@ 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) ===
|
||||||
# The bootstrap operator's username. After seed, set their password
|
SEED_OPERATOR_TELEGRAM_ID=
|
||||||
# via: echo 'change-me-now' | scripts/set-password.sh admin
|
|
||||||
SEED_OPERATOR_USERNAME=admin
|
|
||||||
SEED_OPERATOR_NAME=Operator
|
SEED_OPERATOR_NAME=Operator
|
||||||
|
|
||||||
# === Web / Auth ===
|
# === Web ===
|
||||||
# Port the Next.js container exposes on the host. Production deployment
|
# Port the Next.js container exposes on the host. Production deployment
|
||||||
# (wabot.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000.
|
# (rexwa.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
|
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
-- Add username + password_hash to operators. Backfill the seed row to
|
|
||||||
-- 'admin' so the NOT NULL constraint succeeds; password_hash stays
|
|
||||||
-- nullable so the operator is forced to set one via the CLI before
|
|
||||||
-- they can sign in.
|
|
||||||
ALTER TABLE "operators" ADD COLUMN "username" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "operators" ADD COLUMN "password_hash" text;--> statement-breakpoint
|
|
||||||
UPDATE "operators" SET "username" = 'admin' WHERE "username" IS NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "operators" ALTER COLUMN "username" SET NOT NULL;--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "operators_username_uq" ON "operators" (lower("username"));
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS "operators_telegram_user_id_uq";--> statement-breakpoint
|
|
||||||
ALTER TABLE "operators" DROP COLUMN IF EXISTS "telegram_user_id";
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- Switch the default to 24 ("no deadline" sentinel) so newly-created
|
|
||||||
-- reminders are off-by-default for the optional "Pause sending by"
|
|
||||||
-- toggle, matching the wizard's UX contract.
|
|
||||||
ALTER TABLE "reminders" ALTER COLUMN "delivery_window_end_hour" SET DEFAULT 24;
|
|
||||||
-- Existing rows still hold the old default (18). Treat those as
|
|
||||||
-- "schema-default, never opted in by the operator" and clear them to
|
|
||||||
-- 24 so editing an old reminder doesn't auto-check the deadline box.
|
|
||||||
-- Operators who actually wanted a 6pm deadline can re-enable it from
|
|
||||||
-- the edit form.
|
|
||||||
UPDATE "reminders" SET "delivery_window_end_hour" = 24 WHERE "delivery_window_end_hour" = 18;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "operators" ADD COLUMN "email" text;--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "operators_email_uq" ON "operators" USING btree (lower("email")) WHERE "operators"."email" IS NOT NULL;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
CREATE INDEX IF NOT EXISTS "whatsapp_groups_account_name_idx" ON "whatsapp_groups" USING btree ("account_id","name");
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,111 +1,76 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778311164225,
|
"when": 1778311164225,
|
||||||
"tag": "0000_conscious_tarantula",
|
"tag": "0000_conscious_tarantula",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778320434707,
|
"when": 1778320434707,
|
||||||
"tag": "0001_smart_vertigo",
|
"tag": "0001_smart_vertigo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778338808600,
|
"when": 1778338808600,
|
||||||
"tag": "0002_left_jimmy_woo",
|
"tag": "0002_left_jimmy_woo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778343712901,
|
"when": 1778343712901,
|
||||||
"tag": "0003_messy_bruce_banner",
|
"tag": "0003_messy_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778345543406,
|
"when": 1778345543406,
|
||||||
"tag": "0004_next_prowler",
|
"tag": "0004_next_prowler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778347437350,
|
"when": 1778347437350,
|
||||||
"tag": "0005_flippant_joystick",
|
"tag": "0005_flippant_joystick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778385559051,
|
"when": 1778385559051,
|
||||||
"tag": "0006_adorable_nehzno",
|
"tag": "0006_adorable_nehzno",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778386591494,
|
"when": 1778386591494,
|
||||||
"tag": "0007_overconfident_menace",
|
"tag": "0007_overconfident_menace",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778395584234,
|
"when": 1778395584234,
|
||||||
"tag": "0008_greedy_matthew_murdock",
|
"tag": "0008_greedy_matthew_murdock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778464000000,
|
"when": 1778464000000,
|
||||||
"tag": "0009_rename_ended_to_inactive",
|
"tag": "0009_rename_ended_to_inactive",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
"idx": 10,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778464001000,
|
|
||||||
"tag": "0010_fancy_wolf_cub",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 11,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778464002000,
|
|
||||||
"tag": "0011_premium_grandmaster",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 12,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778464003000,
|
|
||||||
"tag": "0012_lucky_masked_marvel",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 13,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778464004000,
|
|
||||||
"tag": "0013_tricky_yellowjacket",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 14,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778464005000,
|
|
||||||
"tag": "0014_lame_puck",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@ -13,10 +13,6 @@
|
|||||||
"./schema": {
|
"./schema": {
|
||||||
"types": "./dist/schema.d.ts",
|
"types": "./dist/schema.d.ts",
|
||||||
"default": "./dist/schema.js"
|
"default": "./dist/schema.js"
|
||||||
},
|
|
||||||
"./journal-check": {
|
|
||||||
"types": "./dist/journal-check.d.ts",
|
|
||||||
"default": "./dist/journal-check.js"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -30,12 +26,10 @@
|
|||||||
"seed": "tsx src/seed.ts"
|
"seed": "tsx src/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
"pg": "^8.13.0"
|
"pg": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"drizzle-kit": "^0.28.0",
|
"drizzle-kit": "^0.28.0",
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drizzle journal monotonicity guard.
|
|
||||||
*
|
|
||||||
* Background — twice already we hit this regression: a `pnpm migrate`
|
|
||||||
* silently skipped a freshly-generated migration because its `when`
|
|
||||||
* timestamp was older than the previous migration's `when`. Drizzle's
|
|
||||||
* migrator orders the entries by `when` (not by `idx`) and only
|
|
||||||
* applies entries whose `when` is strictly greater than the latest
|
|
||||||
* row's `created_at` in `pgboss... drizzle.__drizzle_migrations`.
|
|
||||||
*
|
|
||||||
* Symptom: migrate prints "Migrations applied." while the schema in
|
|
||||||
* the live DB is missing whatever 0012 / 0013 were supposed to add.
|
|
||||||
* Web 500's on every authenticated request because the code expects
|
|
||||||
* the new columns.
|
|
||||||
*
|
|
||||||
* This module is the first line of defence:
|
|
||||||
* - `assertJournalMonotonic(entries)` is a pure check the test
|
|
||||||
* suite runs against the committed journal file. CI fails on a
|
|
||||||
* bad commit before it can ship.
|
|
||||||
* - migrate.ts calls it on boot. If the live journal in source
|
|
||||||
* control has slipped out of monotonic order, migrate refuses
|
|
||||||
* to run and prints the offending entries with the smallest
|
|
||||||
* bump that would unbreak each one.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface JournalEntry {
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
when: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalCheckResult {
|
|
||||||
ok: boolean;
|
|
||||||
/** Entries whose `when` is <= the previous entry's `when`. */
|
|
||||||
violations: Array<{
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
when: number;
|
|
||||||
/** The previous entry's when — the new bound that this one must beat. */
|
|
||||||
previousWhen: number;
|
|
||||||
previousTag: string;
|
|
||||||
/** A `when` value that would make THIS entry monotonic again. */
|
|
||||||
suggestedWhen: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk the journal entries in idx order and report any whose `when`
|
|
||||||
* is not strictly greater than the previous entry's `when`. The
|
|
||||||
* journal can have any starting timestamp; we only care about the
|
|
||||||
* relative ordering matching idx. Equal timestamps are also a
|
|
||||||
* violation — drizzle requires strictly greater.
|
|
||||||
*/
|
|
||||||
export function assertJournalMonotonic(entries: JournalEntry[]): JournalCheckResult {
|
|
||||||
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
|
|
||||||
const violations: JournalCheckResult["violations"] = [];
|
|
||||||
for (let i = 1; i < sorted.length; i++) {
|
|
||||||
const prev = sorted[i - 1]!;
|
|
||||||
const cur = sorted[i]!;
|
|
||||||
if (cur.when <= prev.when) {
|
|
||||||
violations.push({
|
|
||||||
idx: cur.idx,
|
|
||||||
tag: cur.tag,
|
|
||||||
when: cur.when,
|
|
||||||
previousWhen: prev.when,
|
|
||||||
previousTag: prev.tag,
|
|
||||||
suggestedWhen: prev.when + 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: violations.length === 0, violations };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format the check result into a multi-line human message. */
|
|
||||||
export function formatJournalViolations(result: JournalCheckResult): string {
|
|
||||||
if (result.ok) return "";
|
|
||||||
const lines: string[] = [
|
|
||||||
"Drizzle journal is not monotonic — migrate would silently skip these entries:",
|
|
||||||
];
|
|
||||||
for (const v of result.violations) {
|
|
||||||
lines.push(
|
|
||||||
` ${v.tag} (idx ${v.idx}) when=${v.when} <= ${v.previousTag}.when=${v.previousWhen}`,
|
|
||||||
);
|
|
||||||
lines.push(
|
|
||||||
` fix: set ${v.tag}.when to >= ${v.suggestedWhen} in ` +
|
|
||||||
`packages/db/migrations/meta/_journal.json`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
@ -1,13 +1,5 @@
|
|||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { createClient } from "./index.js";
|
import { createClient } from "./index.js";
|
||||||
import {
|
|
||||||
assertJournalMonotonic,
|
|
||||||
formatJournalViolations,
|
|
||||||
type JournalEntry,
|
|
||||||
} from "./journal-check.js";
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
@ -15,27 +7,6 @@ if (!databaseUrl) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pre-flight: refuse to run if the journal is non-monotonic. -----------
|
|
||||||
// Drizzle silently skips entries whose `when` is older than the previous
|
|
||||||
// entry's `when`. We've hit this twice now (0010/0011, then 0012/0013),
|
|
||||||
// each time the symptom was "Migrations applied." with no schema change
|
|
||||||
// and a 500 in production for the missing column. Catch it before we
|
|
||||||
// hand the journal to drizzle.
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const journalPath = join(__dirname, "..", "migrations", "meta", "_journal.json");
|
|
||||||
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
|
||||||
entries: JournalEntry[];
|
|
||||||
};
|
|
||||||
const check = assertJournalMonotonic(journal.entries);
|
|
||||||
if (!check.ok) {
|
|
||||||
console.error(formatJournalViolations(check));
|
|
||||||
console.error(
|
|
||||||
"\nRefusing to run drizzle migrate. Bump the offending `when` values in\n" +
|
|
||||||
"_journal.json so they're strictly increasing in the same order as `idx`.",
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { db, pool } = createClient(databaseUrl);
|
const { db, pool } = createClient(databaseUrl);
|
||||||
console.log("Applying migrations...");
|
console.log("Applying migrations...");
|
||||||
await migrate(db, { migrationsFolder: "./migrations" });
|
await migrate(db, { migrationsFolder: "./migrations" });
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
uuid,
|
uuid,
|
||||||
@ -10,7 +9,6 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
index,
|
|
||||||
inet,
|
inet,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
@ -18,25 +16,14 @@ export const operators = pgTable(
|
|||||||
"operators",
|
"operators",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
username: text("username").notNull(),
|
telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(),
|
||||||
passwordHash: text("password_hash"),
|
|
||||||
displayName: text("display_name").notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
// Reserved for future contact / recovery flows. Optional + nullable
|
|
||||||
// so today's operators don't have to backfill anything; admins can
|
|
||||||
// populate it from the Users page when we wire that up.
|
|
||||||
email: text("email"),
|
|
||||||
role: text("role").notNull().default("admin"),
|
role: text("role").notNull().default("admin"),
|
||||||
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
|
telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId),
|
||||||
// Case-insensitive uniqueness only when an email IS set (NULLs
|
|
||||||
// remain freely insertable). Lets future flows look up operators
|
|
||||||
// by email without ambiguity.
|
|
||||||
emailUnique: uniqueIndex("operators_email_uq")
|
|
||||||
.on(sql`lower(${t.email})`)
|
|
||||||
.where(sql`${t.email} IS NOT NULL`),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -58,16 +45,6 @@ export const whatsappAccounts = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* whatsapp_groups perf notes (production: 3 000+ rows per account):
|
|
||||||
* - account_jid_uq B-tree (account_id, wa_group_jid).
|
|
||||||
* Backs the on-conflict upsert during
|
|
||||||
* group-sync and every per-account
|
|
||||||
* WHERE-prefix scan.
|
|
||||||
* - whatsapp_groups_name_trgm GIN trgm index on `name` (migration
|
|
||||||
* 0002). Powers fuzzy search via the
|
|
||||||
* `name % term` operator in O(log n).
|
|
||||||
*/
|
|
||||||
export const whatsappGroups = pgTable(
|
export const whatsappGroups = pgTable(
|
||||||
"whatsapp_groups",
|
"whatsapp_groups",
|
||||||
{
|
{
|
||||||
@ -81,16 +58,6 @@ 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,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -123,11 +90,8 @@ export const reminders = pgTable("reminders", {
|
|||||||
// Delivery window (operator timezone). End hour is enforced at runtime
|
// Delivery window (operator timezone). End hour is enforced at runtime
|
||||||
// by fire-reminder when window enforcement lands; start hour is documented
|
// by fire-reminder when window enforcement lands; start hour is documented
|
||||||
// here but not gated in v1.
|
// here but not gated in v1.
|
||||||
// 24 is the "no deadline" sentinel — it's the off-by-default state so a
|
|
||||||
// reminder created without the operator explicitly opting into "Pause
|
|
||||||
// sending by" stays unbounded.
|
|
||||||
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
|
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
|
||||||
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24),
|
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reminderTargets = pgTable(
|
export const reminderTargets = pgTable(
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import { Writable } from "node:stream";
|
|
||||||
import { createClient } from "../index.js";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const username = process.argv[2];
|
|
||||||
const role = process.argv[3];
|
|
||||||
if (!username || (role !== "admin" && role !== "user")) {
|
|
||||||
console.error("Usage: create-user <username> <admin|user>");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
console.error("DATABASE_URL not set");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
|
|
||||||
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
|
|
||||||
process.stdout.write("Password: ");
|
|
||||||
const password = await rl.question("");
|
|
||||||
rl.close();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
if (password.length < 10) {
|
|
||||||
console.error("Password must be at least 10 characters.");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
|
||||||
const { db, pool } = createClient(url);
|
|
||||||
await db.execute(
|
|
||||||
sql`INSERT INTO operators (username, password_hash, display_name, role, default_timezone)
|
|
||||||
VALUES (${username}, ${hash}, ${username}, ${role}, 'Asia/Kuala_Lumpur')`,
|
|
||||||
);
|
|
||||||
await pool.end();
|
|
||||||
console.log(`Created ${role} ${username}.`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import { Writable } from "node:stream";
|
|
||||||
import { createClient } from "../index.js";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const username = process.argv[2];
|
|
||||||
if (!username) {
|
|
||||||
console.error("Usage: set-password <username>");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
console.error("DATABASE_URL not set");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
// Silenced password prompt.
|
|
||||||
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
|
|
||||||
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
|
|
||||||
process.stdout.write("Password: ");
|
|
||||||
const password = await rl.question("");
|
|
||||||
rl.close();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
// Mirrors apps/web/src/lib/password-policy.ts so the CLI bootstrap
|
|
||||||
// path and the server actions stay in sync. Facebook's documented
|
|
||||||
// minimum is 6 chars, with a recommended mix of letters and
|
|
||||||
// numbers/punctuation.
|
|
||||||
if (password.length < 6) {
|
|
||||||
console.error("Password must be at least 6 characters.");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
if (password.length > 256) {
|
|
||||||
console.error("Password is too long.");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const hasLetter = /[A-Za-z]/.test(password);
|
|
||||||
const hasNonLetter = /[^A-Za-z]/.test(password);
|
|
||||||
if (!hasLetter || !hasNonLetter) {
|
|
||||||
console.error("Password must mix letters with numbers or symbols.");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
|
||||||
const { db, pool } = createClient(url);
|
|
||||||
const result = await db.execute(
|
|
||||||
sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`,
|
|
||||||
);
|
|
||||||
await pool.end();
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
console.error(`No user with username ${username}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("Password updated.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,25 +1,29 @@
|
|||||||
import { createClient, operators } from "./index.js";
|
import { createClient, operators } from "./index.js";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
const username = process.env.SEED_OPERATOR_USERNAME ?? "admin";
|
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;
|
||||||
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
|
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
|
||||||
|
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error("DATABASE_URL not set");
|
console.error("DATABASE_URL not set");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if (!operatorTelegramId || operatorTelegramId === "0") {
|
||||||
|
console.error("SEED_OPERATOR_TELEGRAM_ID not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const { db, pool } = createClient(databaseUrl);
|
const { db, pool } = createClient(databaseUrl);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(operators)
|
.insert(operators)
|
||||||
.values({
|
.values({
|
||||||
username,
|
telegramUserId: Number(operatorTelegramId),
|
||||||
displayName: operatorName,
|
displayName: operatorName,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
defaultTimezone: "Asia/Kuala_Lumpur",
|
defaultTimezone: "Asia/Kuala_Lumpur",
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
console.log(`Seeded operator '${username}'. Set a password via scripts/set-password.sh ${username}`);
|
console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`);
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -93,9 +93,6 @@ importers:
|
|||||||
'@types/luxon':
|
'@types/luxon':
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.7.1
|
version: 3.7.1
|
||||||
bcryptjs:
|
|
||||||
specifier: ^3.0.3
|
|
||||||
version: 3.0.3
|
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -169,9 +166,6 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
'@types/bcryptjs':
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.7.0
|
specifier: ^22.7.0
|
||||||
version: 22.19.18
|
version: 22.19.18
|
||||||
@ -208,9 +202,6 @@ importers:
|
|||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
bcryptjs:
|
|
||||||
specifier: ^3.0.3
|
|
||||||
version: 3.0.3
|
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.36.0
|
specifier: ^0.36.0
|
||||||
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
|
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
|
||||||
@ -218,9 +209,6 @@ importers:
|
|||||||
specifier: ^8.13.0
|
specifier: ^8.13.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bcryptjs':
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.7.0
|
specifier: ^22.7.0
|
||||||
version: 22.19.18
|
version: 22.19.18
|
||||||
@ -2382,10 +2370,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@types/bcryptjs@3.0.0':
|
|
||||||
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
|
|
||||||
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
|
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@ -2575,10 +2559,6 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
bcryptjs@3.0.3:
|
|
||||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -6481,10 +6461,6 @@ snapshots:
|
|||||||
'@turbo/windows-arm64@2.9.12':
|
'@turbo/windows-arm64@2.9.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/bcryptjs@3.0.0':
|
|
||||||
dependencies:
|
|
||||||
bcryptjs: 3.0.3
|
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@ -6700,8 +6676,6 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.28: {}
|
baseline-browser-mapping@2.10.28: {}
|
||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@"
|
|
||||||
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