Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4e65b80a | ||
|
|
e52bf67015 | ||
|
|
e909f43fea | ||
|
|
889995e450 | ||
|
|
73fdce1879 | ||
|
|
7e661abd24 | ||
|
|
1f59916efa |
@@ -7,6 +7,36 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.11.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
|
||||
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
|
||||
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
|
||||
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
## [1.10.6] - 2026-10-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
|
||||
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
|
||||
|
||||
## [1.10.5] - 2026-10-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Events that have already started but not yet finished now show on the home page and the Events page. Both pages were hiding an event as soon as its start time passed; they now keep it listed until its end time, which matches what `GET /api/events` already returns.
|
||||
|
||||
## [1.10.4] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -43,7 +43,9 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/users` | public | Register new account |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
|
||||
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
|
||||
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
|
||||
| POST | `/api/users/forgot` | public | Request password reset email |
|
||||
| POST | `/api/users/reset` | public | Reset password with token |
|
||||
| POST | `/api/users/activate` | public | Activate account from email link |
|
||||
@@ -51,9 +53,13 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| PUT | `/api/users/profile` | user+ | Update own profile |
|
||||
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/api/users/close-account` | user+ | Deactivate own account |
|
||||
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
|
||||
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
|
||||
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
|
||||
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
|
||||
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/api/users/:id` | admin | Get user by ID |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
|
||||
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
|
||||
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
|
||||
@@ -71,10 +77,28 @@ Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### POST /api/users/login
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "securepass123" }
|
||||
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
|
||||
```json
|
||||
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
|
||||
```
|
||||
|
||||
### POST /api/users/login/2fa
|
||||
```json
|
||||
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
|
||||
|
||||
### Two-factor setup (`/api/users/2fa/*`)
|
||||
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
|
||||
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off.
|
||||
|
||||
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
|
||||
|
||||
### PUT /api/users/profile
|
||||
```json
|
||||
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
|
||||
|
||||
Generated
+1105
-615
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^9.0.6",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
@@ -42,5 +42,12 @@
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled';
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TwoFactorChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"method" "TwoFactorMethod" NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrustedDevice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -51,6 +51,13 @@ enum SecurityEventType {
|
||||
login
|
||||
password_changed
|
||||
password_reset
|
||||
two_factor_enabled
|
||||
two_factor_disabled
|
||||
}
|
||||
|
||||
enum TwoFactorMethod {
|
||||
email
|
||||
whatsapp
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -67,6 +74,8 @@ model User {
|
||||
lockUntil DateTime?
|
||||
tokenVersion Int @default(0)
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorMethod TwoFactorMethod?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -87,6 +96,9 @@ model User {
|
||||
|
||||
securityEvents SecurityEvent[]
|
||||
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -330,6 +342,40 @@ model PasswordReset {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A 6-digit code sent by email/WhatsApp, either as the second step of a login
|
||||
// ('login') or to prove the channel works before 2FA is switched on ('enable').
|
||||
// Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js).
|
||||
model TwoFactorChallenge {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
purpose String // 'login' | 'enable'
|
||||
method TwoFactorMethod
|
||||
codeHash String
|
||||
attempts Int @default(0)
|
||||
lastSentAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A browser that passed the 2FA code step with "Trust this device" ticked — logins
|
||||
// presenting its token skip the code until it expires or is cleared.
|
||||
model TrustedDevice {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Append-only "Account activity" log for the Profile & Security page (logins, password
|
||||
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
|
||||
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
|
||||
|
||||
@@ -4,6 +4,16 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -124,6 +134,35 @@ async function sendWelcomeEmail(user) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
@@ -198,7 +237,7 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
@@ -278,20 +317,152 @@ const loginUser = async (req, res) => {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Second login step — exchange an emailed/WhatsApp code for a token
|
||||
// @route POST /api/users/login/2fa
|
||||
// @access Public
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a new code for a pending login challenge
|
||||
// @route POST /api/users/login/2fa/resend
|
||||
// @access Public
|
||||
const resendLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.body || {};
|
||||
res.json(await resendChallenge(challengeId, 'login'));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
|
||||
// @route POST /api/users/2fa/enable
|
||||
// @access Private
|
||||
const startEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { method, password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
if (!TWO_FACTOR_METHODS.includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error('Choose email or WhatsApp for your verification codes');
|
||||
}
|
||||
if (!(await availableMethods(user)).includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error(method === 'whatsapp'
|
||||
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
|
||||
: 'Add a valid email address to your profile before turning on two-factor authentication.');
|
||||
}
|
||||
|
||||
// Unlike login, don't fall back to email here — the point of this step is to
|
||||
// prove the channel the user picked actually reaches them.
|
||||
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Finish turning on 2FA with the code that was sent
|
||||
// @route POST /api/users/2fa/confirm
|
||||
// @access Private
|
||||
const confirmEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
});
|
||||
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn off 2FA
|
||||
// @route POST /api/users/2fa/disable
|
||||
// @access Private
|
||||
const disableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
|
||||
});
|
||||
await clearTrustedDevices(user.id);
|
||||
if (user.twoFactorEnabled) {
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-confirm the logged-in user's password without starting a new session
|
||||
// (the self-service kiosk uses this before letting the event be changed)
|
||||
// @route POST /api/users/verify-password
|
||||
// @access Private
|
||||
const verifyPassword = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
|
||||
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(401);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -311,6 +482,8 @@ const getUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
@@ -375,6 +548,11 @@ const updateUserProfile = async (req, res) => {
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -384,6 +562,7 @@ const updateUserProfile = async (req, res) => {
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -393,6 +572,8 @@ const updateUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
@@ -402,6 +583,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
@@ -432,7 +614,7 @@ const getUsers = async (req, res) => {
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
@@ -559,7 +741,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -580,6 +762,14 @@ const updateUser = async (req, res) => {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
// Admins can switch 2FA off for someone who has lost access to their codes,
|
||||
// but never on — enabling requires the user to prove the channel themselves.
|
||||
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
|
||||
if (resetTwoFactor) {
|
||||
data.twoFactorEnabled = false;
|
||||
data.twoFactorMethod = null;
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -590,12 +780,20 @@ const updateUser = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
}
|
||||
if (resetTwoFactor) {
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -646,10 +844,13 @@ const anonymizeUser = async (req, res) => {
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -754,6 +955,7 @@ const resetPassword = async (req, res) => {
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
await clearTrustedDevices(user.id);
|
||||
|
||||
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
@@ -838,6 +1040,7 @@ const revokeMySession = async (req, res) => {
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
await clearTrustedDevices(updated.id);
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
@@ -864,6 +1067,7 @@ const adminRevokeUserSessions = async (req, res) => {
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -901,10 +1105,13 @@ const closeAccount = async (req, res) => {
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -926,6 +1133,7 @@ const closeAccount = async (req, res) => {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -957,6 +1165,12 @@ const getMyActivity = async (req, res) => {
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
|
||||
@@ -391,8 +391,23 @@ app.get('/docs', async (req, res) => {
|
||||
request:{ body:{ email:'jane@example.com', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||||
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
|
||||
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
|
||||
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
|
||||
notes:'At most one send per 60 seconds.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
|
||||
request:{ body:{ email:'jane@example.com' }},
|
||||
responses:[
|
||||
@@ -424,6 +439,25 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
|
||||
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
|
||||
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
|
||||
request:{ body:{ method:'email', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
|
||||
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }}]},
|
||||
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Correct', body:{ ok:true }},
|
||||
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
|
||||
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
|
||||
responses:[
|
||||
|
||||
@@ -3,6 +3,12 @@ const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
|
||||
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
@@ -36,6 +44,10 @@ router.route('/profile')
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
router.post('/verify-password', protect, loginLimiter, verifyPassword);
|
||||
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
|
||||
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
|
||||
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
|
||||
@@ -234,6 +234,28 @@ function buildPasswordResetEmail({ name, resetUrl }) {
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildTwoFactorCodeEmail({ name, code }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Your verification code</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Two-factor authentication for your account</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">Enter this code to continue. It will expire in <strong>10 minutes</strong>.</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;font-size:34px;font-weight:800;letter-spacing:8px;color:#0f172a;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace">${code}</p>
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||
Never share this code with anyone. If you did not try to log in, someone may know your password —
|
||||
change it and contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildPasswordChangedEmail({ name, when, supportEmail }) {
|
||||
const org = getOrg();
|
||||
const contact = supportEmail || org.email;
|
||||
@@ -403,6 +425,7 @@ module.exports = {
|
||||
callout,
|
||||
paymentOption,
|
||||
buildPasswordResetEmail,
|
||||
buildTwoFactorCodeEmail,
|
||||
buildPasswordChangedEmail,
|
||||
buildLoginNotificationEmail,
|
||||
buildWelcomeEmail,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Two-factor authentication helpers: 6-digit codes delivered by email or
|
||||
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
|
||||
*
|
||||
* Neither the code nor the device token is stored in the clear — only an
|
||||
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { describeUserAgent } = require('./userAgent');
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
|
||||
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
|
||||
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
|
||||
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
|
||||
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const METHODS = ['email', 'whatsapp'];
|
||||
|
||||
// Application error carrying the HTTP status the controller should respond with.
|
||||
function fail(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
function hmac(value) {
|
||||
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
// Namespaced so a code hash can never collide with a device-token hash.
|
||||
const hashCode = (code) => hmac(`2fa-code:${code}`);
|
||||
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function hasRealEmail(user) {
|
||||
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@');
|
||||
if (!local || !domain) return '•••';
|
||||
return `${local[0]}•••@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
return `••• ••• ${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Channels this user could receive a code on right now. */
|
||||
async function availableMethods(user) {
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
const methods = [];
|
||||
if (hasRealEmail(user)) methods.push('email');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the code and reports where it actually went. A WhatsApp failure (session
|
||||
* disconnected, not configured) falls back to email so a user is never locked out
|
||||
* by the WhatsApp gateway being down. Throws when no channel could deliver.
|
||||
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
|
||||
*/
|
||||
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
|
||||
}
|
||||
|
||||
if (method === 'whatsapp') {
|
||||
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
|
||||
const { buildWATwoFactorCode } = require('./waMessages');
|
||||
try {
|
||||
// sendText silently no-ops when WhatsApp isn't set up, so check first.
|
||||
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
|
||||
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
|
||||
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
|
||||
} catch (e) {
|
||||
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
|
||||
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
|
||||
try {
|
||||
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
|
||||
} catch (e) {
|
||||
console.warn('[2fa] Email code failed:', e?.message || e);
|
||||
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
return { method: 'email', destination: maskEmail(user.email) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a fresh code and records the challenge. Any earlier challenge of the same
|
||||
* purpose for this user is discarded, so only the newest code works.
|
||||
*/
|
||||
async function createChallenge(user, purpose, method, options) {
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(user, method, code, options);
|
||||
|
||||
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
|
||||
const challenge = await prisma.twoFactorChallenge.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
purpose,
|
||||
method: sent.method,
|
||||
codeHash: hashCode(code),
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a submitted code. Returns the challenge (with its user) on success and
|
||||
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
|
||||
*/
|
||||
async function verifyChallenge(challengeId, code, purpose, userId) {
|
||||
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw invalid();
|
||||
const submitted = String(code ?? '').replace(/\D/g, '');
|
||||
|
||||
// Count the attempt before comparing, atomically, so parallel guesses can't
|
||||
// slip past the attempt limit.
|
||||
const counted = await prisma.twoFactorChallenge.updateMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
purpose,
|
||||
...(userId ? { userId } : {}),
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
attempts: { lt: MAX_ATTEMPTS },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
if (counted.count !== 1) throw invalid();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
|
||||
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
|
||||
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
|
||||
}
|
||||
|
||||
// Single use — if two requests race with the right code, only one wins.
|
||||
const used = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (used.count !== 1) throw invalid();
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/** Replaces the code on an existing challenge and sends it again. */
|
||||
async function resendChallenge(challengeId, purpose, userId) {
|
||||
const expired = () => fail('This verification has expired. Please start again.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw expired();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
|
||||
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
|
||||
|
||||
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
|
||||
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
|
||||
|
||||
// Claim the send slot first so two simultaneous resends can't both go out.
|
||||
const claimed = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
|
||||
data: { lastSentAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
|
||||
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(challenge.user, challenge.method, code);
|
||||
await prisma.twoFactorChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
|
||||
});
|
||||
|
||||
return { challengeId, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
|
||||
async function issueTrustedDevice(userId, userAgent) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
|
||||
await prisma.trustedDevice.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashDeviceToken(token),
|
||||
device: describeUserAgent(userAgent),
|
||||
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** True when `token` is an unexpired trusted-device token belonging to this user. */
|
||||
async function isTrustedDevice(userId, token) {
|
||||
if (!token || typeof token !== 'string') return false;
|
||||
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
|
||||
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
|
||||
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
|
||||
async function clearTrustedDevices(userId) {
|
||||
try {
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId } });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear trusted devices:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
};
|
||||
@@ -149,6 +149,25 @@ function buildWALogin({ name, when, location, userAgent }) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Two-factor code ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildWATwoFactorCode({ name, code }) {
|
||||
const org = getOrg();
|
||||
return [
|
||||
`🔑 *Your verification code*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your *${org.name}* verification code is:`,
|
||||
'',
|
||||
`*${code}*`,
|
||||
'',
|
||||
`_It expires in 10 minutes. Never share this code with anyone._`,
|
||||
'',
|
||||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAWelcome({ name, events }) {
|
||||
@@ -306,6 +325,7 @@ module.exports = {
|
||||
buildWADonationAppliedToRegistrant,
|
||||
buildWADonationUnassignedFromRegistrant,
|
||||
buildWALogin,
|
||||
buildWATwoFactorCode,
|
||||
buildWAWelcome,
|
||||
buildWAAccountClosed,
|
||||
buildWATicketCaption,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
// The pure helpers under test never touch the database.
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const {
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
describe('generateCode', () => {
|
||||
test('always returns exactly six digits', () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
expect(generateCode()).toMatch(/^\d{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashCode / hashDeviceToken', () => {
|
||||
test('is deterministic and never returns the input', () => {
|
||||
expect(hashCode('123456')).toBe(hashCode('123456'));
|
||||
expect(hashCode('123456')).not.toContain('123456');
|
||||
expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('different codes hash differently', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashCode('123457'));
|
||||
});
|
||||
|
||||
test('code and device-token hashes are namespaced apart', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashDeviceToken('123456'));
|
||||
});
|
||||
|
||||
test('depends on JWT_SECRET', () => {
|
||||
const before = hashCode('123456');
|
||||
process.env.JWT_SECRET = 'another-secret';
|
||||
expect(hashCode('123456')).not.toBe(before);
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
});
|
||||
});
|
||||
|
||||
describe('masking', () => {
|
||||
test('maskEmail keeps the first letter and the domain', () => {
|
||||
expect(maskEmail('jane@example.com')).toBe('j•••@example.com');
|
||||
});
|
||||
|
||||
test('maskEmail copes with junk input', () => {
|
||||
expect(maskEmail('')).toBe('•••');
|
||||
expect(maskEmail(null)).toBe('•••');
|
||||
});
|
||||
|
||||
test('maskPhone keeps only the last four digits', () => {
|
||||
expect(maskPhone('27821234567')).toBe('••• ••• 4567');
|
||||
expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRealEmail', () => {
|
||||
test('accepts a normal address', () => {
|
||||
expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects guest and deleted placeholders and missing values', () => {
|
||||
expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false);
|
||||
expect(hasRealEmail({})).toBe(false);
|
||||
expect(hasRealEmail(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
|
||||
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
|
||||
function matches(row, where = {}) {
|
||||
return Object.entries(where).every(([key, cond]) => {
|
||||
const value = row[key];
|
||||
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
|
||||
if ('gt' in cond) return value > cond.gt;
|
||||
if ('lt' in cond) return value < cond.lt;
|
||||
}
|
||||
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
|
||||
return value === cond;
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(row, data) {
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
|
||||
}
|
||||
}
|
||||
|
||||
function makeModel(defaults, relations = {}) {
|
||||
let rows = [];
|
||||
let seq = 0;
|
||||
const withRelations = (row, include) => {
|
||||
if (!row) return null;
|
||||
const out = { ...row };
|
||||
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
_rows: () => rows,
|
||||
_reset: () => { rows = []; },
|
||||
create: async ({ data }) => {
|
||||
const row = { id: `id-${++seq}`, ...defaults(), ...data };
|
||||
rows.push(row);
|
||||
return { ...row };
|
||||
},
|
||||
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
|
||||
update: async ({ where, data }) => {
|
||||
const row = rows.find(r => matches(r, where));
|
||||
applyData(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
updateMany: async ({ where, data }) => {
|
||||
const hit = rows.filter(r => matches(r, where));
|
||||
hit.forEach(r => applyData(r, data));
|
||||
return { count: hit.length };
|
||||
},
|
||||
deleteMany: async ({ where }) => {
|
||||
const before = rows.length;
|
||||
rows = rows.filter(r => !matches(r, where));
|
||||
return { count: before - rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
|
||||
|
||||
const mockDb = {
|
||||
twoFactorChallenge: makeModel(
|
||||
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
|
||||
{ user: () => USER },
|
||||
),
|
||||
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
|
||||
};
|
||||
|
||||
const mockSent = { emails: [], whatsapps: [] };
|
||||
const mockWhatsApp = { configured: true, fail: false };
|
||||
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({
|
||||
sendMail: async (mail) => { mockSent.emails.push(mail); },
|
||||
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
|
||||
}));
|
||||
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockWhatsApp.configured,
|
||||
sendText: async (to, message) => {
|
||||
if (mockWhatsApp.fail) throw new Error('session disconnected');
|
||||
mockSent.whatsapps.push({ to, message });
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
MAX_ATTEMPTS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
|
||||
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
|
||||
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb.twoFactorChallenge._reset();
|
||||
mockDb.trustedDevice._reset();
|
||||
mockSent.emails = [];
|
||||
mockSent.whatsapps = [];
|
||||
mockWhatsApp.configured = true;
|
||||
mockWhatsApp.fail = false;
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('availableMethods', () => {
|
||||
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
|
||||
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
|
||||
mockWhatsApp.configured = false;
|
||||
expect(await availableMethods(USER)).toEqual(['email']);
|
||||
mockWhatsApp.configured = true;
|
||||
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
|
||||
});
|
||||
|
||||
test('offers nothing by email for a guest placeholder address', async () => {
|
||||
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChallenge', () => {
|
||||
test('emails the code and stores only its hash', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'email');
|
||||
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
|
||||
const [row] = mockDb.twoFactorChallenge._rows();
|
||||
expect(row.id).toBe(res.challengeId);
|
||||
expect(row.codeHash).not.toContain(lastEmailCode());
|
||||
expect(mockSent.emails[0].to).toBe(USER.email);
|
||||
});
|
||||
|
||||
test('sends by WhatsApp when asked', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'whatsapp');
|
||||
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
|
||||
expect(mockSent.whatsapps).toHaveLength(1);
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('falls back to email when WhatsApp fails or is not configured', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
mockWhatsApp.fail = false;
|
||||
mockWhatsApp.configured = false;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('does not fall back when allowFallback is false, and records no challenge', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a new challenge replaces the previous one for the same purpose', async () => {
|
||||
const first = await createChallenge(USER, 'login', 'email');
|
||||
const firstCode = lastEmailCode();
|
||||
await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyChallenge', () => {
|
||||
test('accepts the right code exactly once', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
expect(challenge.userId).toBe(USER.id);
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('tolerates spaces in the submitted code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
||||
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
|
||||
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
|
||||
);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects an expired code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects a code used for the wrong purpose or by another user', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'enable', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('rejects missing or malformed input', async () => {
|
||||
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
|
||||
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendChallenge', () => {
|
||||
test('refuses inside the cooldown', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
|
||||
expect(mockSent.emails).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const oldCode = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
|
||||
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
|
||||
await resendChallenge(challengeId, 'login');
|
||||
const newCode = lastEmailCode();
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
|
||||
if (newCode !== oldCode) {
|
||||
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('refuses once the challenge is too old or already used', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const row = mockDb.twoFactorChallenge._rows()[0];
|
||||
row.lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
|
||||
row.createdAt = new Date();
|
||||
row.usedAt = new Date();
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trusted devices', () => {
|
||||
test('a freshly issued token is trusted for its own user only', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(true);
|
||||
expect(await isTrustedDevice('someone-else', token)).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown, malformed and expired tokens are not trusted', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, '');
|
||||
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
|
||||
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(false);
|
||||
});
|
||||
|
||||
test('clearTrustedDevices forgets every device for the user', async () => {
|
||||
const a = await issueTrustedDevice(USER.id, '');
|
||||
const b = await issueTrustedDevice(USER.id, '');
|
||||
await clearTrustedDevices(USER.id);
|
||||
expect(await isTrustedDevice(USER.id, a)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+242
-182
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -768,9 +768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -786,13 +786,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -808,20 +808,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -831,9 +831,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -847,9 +847,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -863,12 +863,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -879,12 +882,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -895,12 +901,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -911,12 +920,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -927,12 +939,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -943,12 +958,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -959,12 +977,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -975,12 +996,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -991,12 +1015,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1009,16 +1036,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1031,16 +1061,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1053,16 +1086,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1075,16 +1111,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1097,16 +1136,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1119,16 +1161,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1141,16 +1186,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1163,13 +1211,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1183,16 +1231,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1202,9 +1250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1221,9 +1269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1240,9 +1288,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1353,15 +1401,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.24.tgz",
|
||||
"integrity": "sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
|
||||
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.24.tgz",
|
||||
"integrity": "sha512-K6jcYhwMY73qpeJ5SP+rrXs7Fuvv94HEvOCEen/zjwG7aq83utZY8n+8YdKHZR1lv0n5cB4z9C6QqwpE9acN+A==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.27.tgz",
|
||||
"integrity": "sha512-a94xlzvOLaULh4Fzcvj9B2hwIOtPHJW75gghkXiJ8s1Jk0eo00UknrDVhx04Y+8ARu9ds13vy/jHg5u/PdVGqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1369,9 +1417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.24.tgz",
|
||||
"integrity": "sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
|
||||
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1385,9 +1433,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.24.tgz",
|
||||
"integrity": "sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
|
||||
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1401,12 +1449,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.24.tgz",
|
||||
"integrity": "sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
|
||||
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1417,12 +1468,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.24.tgz",
|
||||
"integrity": "sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
|
||||
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1433,12 +1487,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.24.tgz",
|
||||
"integrity": "sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
|
||||
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1449,12 +1506,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.24.tgz",
|
||||
"integrity": "sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
|
||||
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1465,9 +1525,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.24.tgz",
|
||||
"integrity": "sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
|
||||
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1481,9 +1541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.24.tgz",
|
||||
"integrity": "sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
|
||||
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3897,9 +3957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4990,9 +5050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5872,13 +5932,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.24.tgz",
|
||||
"integrity": "sha512-RXne8azyj9DKV9N15elyRsnh98BtKINYHCG7bmVhpIhaOtaYvrNJ37XwWpjY9Z09f5RHYOf9vn5uU0MgV1iLrw==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.27.tgz",
|
||||
"integrity": "sha512-SlpZ5Ch1bM+GNHxahIoCy5OVgbpIHzHoGQDjHo6vEhxvYMukWYVo++AEh9RWYaIxH1i38PO7ArzSdmTruswOew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.24",
|
||||
"@next/eslint-plugin-next": "15.5.27",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
@@ -6287,9 +6347,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
|
||||
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6624,9 +6684,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -7848,12 +7908,12 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.24",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.24.tgz",
|
||||
"integrity": "sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==",
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
|
||||
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.24",
|
||||
"@next/env": "15.5.27",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -7866,15 +7926,15 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.5.24",
|
||||
"@next/swc-darwin-x64": "15.5.24",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.24",
|
||||
"@next/swc-linux-arm64-musl": "15.5.24",
|
||||
"@next/swc-linux-x64-gnu": "15.5.24",
|
||||
"@next/swc-linux-x64-musl": "15.5.24",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.24",
|
||||
"@next/swc-win32-x64-msvc": "15.5.24",
|
||||
"sharp": "^0.34.3 || ^0.35.3"
|
||||
"@next/swc-darwin-arm64": "15.5.27",
|
||||
"@next/swc-darwin-x64": "15.5.27",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.27",
|
||||
"@next/swc-linux-arm64-musl": "15.5.27",
|
||||
"@next/swc-linux-x64-gnu": "15.5.27",
|
||||
"@next/swc-linux-x64-musl": "15.5.27",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.27",
|
||||
"@next/swc-win32-x64-msvc": "15.5.27",
|
||||
"sharp": "^0.34.3 || ^0.35.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -9064,9 +9124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -9081,31 +9141,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
"@img/sharp-darwin-arm64": "0.35.5",
|
||||
"@img/sharp-darwin-x64": "0.35.5",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||
"@img/sharp-linux-arm": "0.35.5",
|
||||
"@img/sharp-linux-arm64": "0.35.5",
|
||||
"@img/sharp-linux-ppc64": "0.35.5",
|
||||
"@img/sharp-linux-riscv64": "0.35.5",
|
||||
"@img/sharp-linux-s390x": "0.35.5",
|
||||
"@img/sharp-linux-x64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||
"@img/sharp-win32-arm64": "0.35.5",
|
||||
"@img/sharp-win32-ia32": "0.35.5",
|
||||
"@img/sharp-win32-x64": "0.35.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
@@ -9235,9 +9295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -61,5 +61,9 @@
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface UserItem {
|
||||
role: string;
|
||||
phoneNumber?: string | null;
|
||||
notificationPreference?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -186,6 +187,16 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetTwoFactor = async (id: string, name: string) => {
|
||||
if (!confirm(`Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = async (id: string) => {
|
||||
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
|
||||
try {
|
||||
@@ -352,6 +363,9 @@ export default function AdminUsersPage() {
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
||||
{u.twoFactorEnabled && (
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name)} title="Turn off two-factor authentication for a user who can no longer receive their codes">Reset 2FA</button>
|
||||
)}
|
||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,21 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
|
||||
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
|
||||
|
||||
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
|
||||
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
|
||||
password_changed: { label: () => "Password changed", icon: KeyRound },
|
||||
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
|
||||
two_factor_enabled: { label: () => "Two-factor authentication turned on", icon: ShieldCheck },
|
||||
two_factor_disabled: { label: () => "Two-factor authentication turned off", icon: ShieldOff },
|
||||
};
|
||||
|
||||
const TWO_FACTOR_METHOD_LABEL: Record<TwoFactorMethod, string> = { email: "email", whatsapp: "WhatsApp" };
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { user, token, logout, updateToken } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -152,6 +157,102 @@ export default function UserProfilePage() {
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────────────────────
|
||||
// Mirrors the saved account state locally: the auth context's `user` is only
|
||||
// loaded once per page load, so it wouldn't reflect a change made here.
|
||||
const [tfEnabled, setTfEnabled] = useState(false);
|
||||
const [tfMethod, setTfMethod] = useState<TwoFactorMethod>("email");
|
||||
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable">("idle");
|
||||
const [tfPassword, setTfPassword] = useState("");
|
||||
const [tfCode, setTfCode] = useState("");
|
||||
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [tfMsg, setTfMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [tfBusy, setTfBusy] = useState(false);
|
||||
|
||||
// WhatsApp codes need the number that's actually saved, not one still being typed.
|
||||
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setTfEnabled(!!user.twoFactorEnabled);
|
||||
setTfMethod(user.twoFactorMethod || "email");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const resetTwoFactorForm = () => {
|
||||
setTfStep("idle");
|
||||
setTfPassword("");
|
||||
setTfCode("");
|
||||
setTfChallenge(null);
|
||||
};
|
||||
|
||||
const startEnableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { method: tfMethod, password: tfPassword },
|
||||
});
|
||||
setTfChallenge(res);
|
||||
setTfCode("");
|
||||
setTfPassword("");
|
||||
setTfStep("code");
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !tfChallenge) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { challengeId: tfChallenge.challengeId, code: tfCode },
|
||||
});
|
||||
setTfEnabled(true);
|
||||
setTfMethod(res.twoFactorMethod);
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is on." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/users/2fa/disable", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: tfPassword },
|
||||
});
|
||||
setTfEnabled(false);
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Account closure ───────────────────────────────────────────────────────
|
||||
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
@@ -299,6 +400,94 @@ export default function UserProfilePage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Two-factor authentication ────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
|
||||
|
||||
{tfEnabled ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<span className="font-medium text-green-700">On.</span> When you log in we send a 6-digit code by{" "}
|
||||
{TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days.
|
||||
</p>
|
||||
{tfStep !== "disable" ? (
|
||||
<button
|
||||
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
|
||||
{tfBusy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : tfStep === "code" && tfChallenge ? (
|
||||
<form onSubmit={confirmEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. Enter it to finish turning on two-factor authentication.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
className={`${inputCls} tracking-widest`}
|
||||
value={tfCode}
|
||||
onChange={e => setTfCode(e.target.value.replace(/\D/g, ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || tfCode.length !== 6} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Verifying…" : "Turn on two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={startEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Add a second step to logging in: after your password, we send a 6-digit code that you need to enter.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
|
||||
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
|
||||
<option value="email">Email</option>
|
||||
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
|
||||
</select>
|
||||
{!canWhatsAppCodes && (
|
||||
<p className="mt-1 text-xs text-gray-500">Save a valid phone number on your profile to receive codes by WhatsApp.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Sending code…" : "Send verification code"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{tfMsg && <Alert msg={tfMsg} />}
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Metadata } from "next";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetchListForPrerender } from "@/lib/api";
|
||||
import { Calendar, CalendarX } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -27,11 +27,11 @@ type Event = {
|
||||
};
|
||||
|
||||
export default async function EventsPage() {
|
||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
if (e.cashupStatus === "closed") return false;
|
||||
const t = new Date(e.startDate).getTime();
|
||||
const t = new Date(e.endDate || e.startDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
@@ -33,6 +33,9 @@ const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
* to fail the whole page render over.
|
||||
*/
|
||||
async function getServerSettings(): Promise<SiteSettings> {
|
||||
// No API base configured: a relative fetch never settles during prerender and
|
||||
// would time out every page, so skip straight to the defaults.
|
||||
if (!API_BASE) return {};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
|
||||
@@ -18,7 +18,7 @@ type Event = {
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetchListForPrerender } from "@/lib/api";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
|
||||
@@ -28,6 +28,7 @@ const FEATURES = [
|
||||
];
|
||||
|
||||
async function getOrgName(): Promise<string> {
|
||||
if (!API_BASE) return appName;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return appName;
|
||||
@@ -40,12 +41,12 @@ async function getOrgName(): Promise<string> {
|
||||
|
||||
export default async function HomePage() {
|
||||
const [events, displayName] = await Promise.all([
|
||||
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
getOrgName(),
|
||||
]);
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
const t = new Date(e.startDate).getTime();
|
||||
const t = new Date(e.endDate || e.startDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -133,6 +134,9 @@ export default function SelfServicePage() {
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
// Set while a supervisor with two-factor on still has to enter their code.
|
||||
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [setupCode, setSetupCode] = useState("");
|
||||
const [events, setEvents] = useState<KioskEvent[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState("");
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
@@ -277,16 +281,32 @@ export default function SelfServicePage() {
|
||||
setSetupError(null);
|
||||
setSetupLoading(true);
|
||||
try {
|
||||
const data = await apiFetch("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
// Second pass for accounts with two-factor on: exchange the code that was
|
||||
// sent after the password step. The kiosk never asks to be remembered.
|
||||
const data = setupChallenge
|
||||
? await apiFetch("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||
})
|
||||
: await apiFetch("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
if (isTwoFactorChallenge(data)) {
|
||||
setSetupChallenge(data);
|
||||
setSetupCode("");
|
||||
return;
|
||||
}
|
||||
const role: string = data.role || "";
|
||||
if (!["admin", "supervisor"].includes(role)) {
|
||||
setSetupError("Only admins and supervisors can set up the kiosk.");
|
||||
setSetupChallenge(null);
|
||||
setSetupCode("");
|
||||
setSetupLoading(false);
|
||||
return;
|
||||
}
|
||||
setSetupChallenge(null);
|
||||
setSetupCode("");
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
} catch (err: any) {
|
||||
@@ -316,18 +336,16 @@ export default function SelfServicePage() {
|
||||
setChangeError(null);
|
||||
setChangeLoading(true);
|
||||
try {
|
||||
const data = await apiFetch("/api/users/login", {
|
||||
// Re-confirm the password against the supervisor session the kiosk already
|
||||
// holds, rather than logging in again — a fresh login would send a new
|
||||
// two-factor code every time the event is changed.
|
||||
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
|
||||
await apiFetch("/api/users/verify-password", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: changePassword },
|
||||
authToken: supervisorToken,
|
||||
body: { password: changePassword },
|
||||
});
|
||||
const role: string = data.role || "";
|
||||
if (!["admin", "supervisor"].includes(role)) {
|
||||
setChangeError("Only admins and supervisors can change the event.");
|
||||
setChangeLoading(false);
|
||||
return;
|
||||
}
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
await loadEvents(supervisorToken);
|
||||
setChangePassword("");
|
||||
setChangeError(null);
|
||||
setShowChangeModal(false);
|
||||
@@ -701,13 +719,40 @@ export default function SelfServicePage() {
|
||||
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{setupChallenge && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={setupCode}
|
||||
onChange={(e) => setSetupCode(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base tracking-widest focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="6-digit code"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand-600 hover:underline"
|
||||
onClick={() => { setSetupChallenge(null); setSetupCode(""); setSetupError(null); }}
|
||||
>
|
||||
Start again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
|
||||
>
|
||||
{setupLoading ? "Signing in…" : "Sign In"}
|
||||
{setupLoading ? "Signing in…" : setupChallenge ? "Verify" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// Matches the backend's minimum gap between code sends.
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth();
|
||||
const { login, verifyTwoFactor, resendTwoFactor } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("redirect") || "";
|
||||
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
|
||||
@@ -19,6 +23,25 @@ export function LoginForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Two-factor step — set once the password is accepted for an account with 2FA on.
|
||||
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [rememberDevice, setRememberDevice] = useState(false);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
|
||||
// Skip the generic /dashboard hop (which just redirects again once the role is known)
|
||||
// and go straight to the role-specific dashboard, unless the caller asked for a specific
|
||||
// page back (e.g. returning to a registration flow after login).
|
||||
const goToDashboard = (loggedInUser: AuthUser) => {
|
||||
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: any) => {
|
||||
if (!err) return "Login failed";
|
||||
|
||||
@@ -45,12 +68,15 @@ export function LoginForm() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const loggedInUser = await login(email, password);
|
||||
// Skip the generic /dashboard hop (which just redirects again once the role is known)
|
||||
// and go straight to the role-specific dashboard, unless the caller asked for a specific
|
||||
// page back (e.g. returning to a registration flow after login).
|
||||
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
window.location.href = target;
|
||||
const result = await login(email, password);
|
||||
if (isTwoFactorChallenge(result)) {
|
||||
// Password was right, but the account has two-factor on — show the code step.
|
||||
setChallenge(result);
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
return;
|
||||
}
|
||||
goToDashboard(result);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err) || "Login failed. Please try again.");
|
||||
} finally {
|
||||
@@ -58,6 +84,102 @@ export function LoginForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!challenge) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err) || "Verification failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onResend = async () => {
|
||||
if (!challenge || resendIn > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
setChallenge(await resendTwoFactor(challenge.challengeId));
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const backToPassword = () => {
|
||||
setChallenge(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (challenge) {
|
||||
return (
|
||||
<form onSubmit={onSubmitCode} className="space-y-4 max-w-sm w-full">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Enter your verification code</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
We sent a 6-digit code to {challenge.destination} by {challenge.method === "whatsapp" ? "WhatsApp" : "email"}. It expires in 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full border rounded px-3 py-2 text-lg tracking-widest"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={rememberDevice}
|
||||
onChange={(e) => setRememberDevice(e.target.checked)}
|
||||
/>
|
||||
<span>Trust this device for 30 days. Only tick this on a device that's yours.</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length !== 6}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and log in"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={backToPassword} className="text-brand-600 hover:underline">
|
||||
Back to login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={resendIn > 0}
|
||||
className="text-brand-600 hover:underline disabled:text-gray-400 disabled:no-underline"
|
||||
>
|
||||
{resendIn > 0 ? `Resend code in ${resendIn}s` : "Resend code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
|
||||
<div>
|
||||
|
||||
@@ -27,6 +27,9 @@ export const authHelpContent: HelpContent = {
|
||||
<GuideItem icon={LogIn} title="Already registered as a guest?" tone="green">
|
||||
If you registered as a guest before, you can still create a full account later using the same email — your past registrations will be there once you're logged in.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldCheck} title="Asked for a verification code?" tone="amber">
|
||||
If you've turned on two-factor authentication, we send a 6-digit code by email or WhatsApp after your password. Enter it within 10 minutes, or use "Resend code" if it doesn't arrive. Tick "Trust this device" to skip the code on that device for 30 days. You can turn two-factor on or off from Profile & Security.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { AuthResponse, AuthUser, clearToken, getToken, saveToken } from "@/lib/auth";
|
||||
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser>;
|
||||
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateToken: (token: string) => void;
|
||||
@@ -49,18 +51,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
});
|
||||
const { token, ...loggedInUser } = res;
|
||||
const startSession = useCallback((res: AuthResponse) => {
|
||||
const { token, deviceToken, ...loggedInUser } = res;
|
||||
if (deviceToken) saveDeviceToken(deviceToken);
|
||||
saveToken(token);
|
||||
setToken(token);
|
||||
setUser(loggedInUser);
|
||||
return loggedInUser;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email, password, deviceToken: getDeviceToken() || undefined },
|
||||
});
|
||||
// 2FA accounts get a challenge back instead of a token — nothing is saved
|
||||
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
|
||||
if (isTwoFactorChallenge(res)) return res;
|
||||
return startSession(res as AuthResponse);
|
||||
}, [startSession]);
|
||||
|
||||
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId, code, rememberDevice },
|
||||
});
|
||||
return startSession(res);
|
||||
}, [startSession]);
|
||||
|
||||
const resendTwoFactor = useCallback((challengeId: string) => {
|
||||
return apiFetch<TwoFactorChallenge>("/api/users/login/2fa/resend", {
|
||||
method: "POST",
|
||||
body: { challengeId },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users", {
|
||||
method: "POST",
|
||||
@@ -87,8 +112,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, token, loading, login, register, logout, updateToken }),
|
||||
[loading, login, logout, register, token, updateToken, user]
|
||||
() => ({ user, token, loading, login, verifyTwoFactor, resendTwoFactor, register, logout, updateToken }),
|
||||
[loading, login, logout, register, resendTwoFactor, token, updateToken, user, verifyTwoFactor]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -84,6 +84,13 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
|
||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
||||
|
||||
// On the server a relative URL has no origin to resolve against, and Next's
|
||||
// patched fetch never settles on one during prerender (the build then times out
|
||||
// every page). Fail fast instead so callers can degrade.
|
||||
if (typeof window === "undefined" && !url.startsWith("http")) {
|
||||
throw new Error(`API base URL is not configured (set NEXT_PUBLIC_API_URL or API_URL); cannot fetch ${path}`);
|
||||
}
|
||||
|
||||
const mergedHeaders: Record<string, string> = { ...headers };
|
||||
if (!(body instanceof FormData)) {
|
||||
mergedHeaders["Content-Type"] = "application/json";
|
||||
@@ -120,6 +127,20 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
return res.text();
|
||||
}
|
||||
|
||||
// For server components that prerender a public list: during `next build` the
|
||||
// backend is often not running (or no API URL is set), so fall back to an empty
|
||||
// list rather than failing the build — the page fills in on its first revalidate.
|
||||
// At runtime the error still propagates, so ISR keeps serving the last good page
|
||||
// instead of caching an empty one.
|
||||
export async function apiFetchListForPrerender<T>(path: string, options: ApiOptions = {}): Promise<T[]> {
|
||||
try {
|
||||
return (await apiFetch<T[]>(path, options)) || [];
|
||||
} catch (err) {
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
||||
// `limit` at 200 server-side, so a single request can silently miss records
|
||||
// beyond the first page. This walks every page and returns the full list.
|
||||
|
||||
@@ -5,13 +5,43 @@ export type AuthUser = {
|
||||
role?: "admin" | "supervisor" | "staff" | "user";
|
||||
phoneNumber?: string | null;
|
||||
notificationPreference?: "email" | "whatsapp" | "both";
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: TwoFactorMethod | null;
|
||||
};
|
||||
|
||||
export type AuthResponse = AuthUser & {
|
||||
token: string;
|
||||
// Only present when the user ticked "Trust this device" on the 2FA step.
|
||||
deviceToken?: string;
|
||||
};
|
||||
|
||||
export type TwoFactorMethod = "email" | "whatsapp";
|
||||
|
||||
// What the login endpoint returns instead of a token when the account has 2FA on:
|
||||
// a code was sent to `destination` (masked) and must be exchanged for the token.
|
||||
export type TwoFactorChallenge = {
|
||||
twoFactorRequired?: true;
|
||||
challengeId: string;
|
||||
method: TwoFactorMethod;
|
||||
destination: string;
|
||||
};
|
||||
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
||||
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "hope_events_token";
|
||||
// Remembers this browser after a 2FA code so the next login can skip it. Kept on
|
||||
// logout on purpose — it identifies the device, not the session.
|
||||
const DEVICE_KEY = "hope_events_device";
|
||||
|
||||
export function saveDeviceToken(token: string) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(DEVICE_KEY, token);
|
||||
}
|
||||
export function getDeviceToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(DEVICE_KEY);
|
||||
}
|
||||
|
||||
export function saveToken(token: string) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
@@ -196,9 +196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.10.4",
|
||||
"version": "1.11.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -17,5 +17,8 @@
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"overrides": {
|
||||
"shell-quote": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user