From c9efcf6c638592b57d0cf13f16176e1578fd5f9f Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 12:10:15 +0500 Subject: [PATCH 1/6] fix: submission failure path --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/services/submissionService.js | 20 +++++---- backend/tests/api.test.js | 29 +++++++++++++ backend/tests/helpers.js | 6 ++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/context/LibraryProvider.jsx | 52 ++++++++++++++++------- frontend/src/pages/Landing.jsx | 4 +- frontend/src/pages/RecordingEditor.jsx | 2 +- 10 files changed, 90 insertions(+), 35 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 79d4ba4..57bdb30 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "axios": "^1.20.0", diff --git a/backend/package.json b/backend/package.json index 561475d..1a20fec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "description": "SignPakCommons API: Express, Node.js and MongoDB", "main": "server.js", "scripts": { diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js index bcb118c..797ff8e 100644 --- a/backend/src/services/submissionService.js +++ b/backend/src/services/submissionService.js @@ -40,21 +40,23 @@ export const submissionService = { const claimed = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS) if (!claimed) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id)) - const sequence = await submissionRepo.countForPair(user._id, video._id) - const categoryName = archiveSlug(category.label) - const videoName = archiveSlug(video.title) - const archiveFolder = `commons/${user._id}_${sequence}/${categoryName}/${videoName}` - const recording = await storageService.archive({ - tempPath: upload.path, originalName: upload.originalname, mimeType: kind.mimeType, ext: kind.ext, - folder: archiveFolder, fileName: `${videoName}${kind.ext}`, - }) + let recording try { + const sequence = await submissionRepo.countForPair(user._id, video._id) + const categoryName = archiveSlug(category.label) + const videoName = archiveSlug(video.title) + const archiveFolder = `commons/${user._id}_${sequence}/${categoryName}/${videoName}` + recording = await storageService.archive({ + tempPath: upload.path, originalName: upload.originalname, mimeType: kind.mimeType, ext: kind.ext, + folder: archiveFolder, fileName: `${videoName}${kind.ext}`, + }) return await submissionRepo.create({ user: user._id, video: video._id, trimStart: input.trimStart, trimEnd: input.trimEnd, mirrored: input.mirrored, duration: input.duration, recording, }) } catch (error) { - await submissionRepo.releaseCooldown(user._id, video._id) // this attempt never landed, so it should not cost the contributor their cooldown + await storageService.remove(recording) + await submissionRepo.releaseCooldown(user._id, video._id) throw error } }, diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index 8377088..709d26d 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -6,6 +6,7 @@ const { default: app } = await import('../src/app.js') const { connectDb, disconnectDb } = await import('../src/config/db.js') const { authService } = await import('../src/services/authService.js') const { storageService } = await import('../src/services/storage/index.js') +const { submissionRepo } = await import('../src/repositories/submissionRepo.js') const { Submission } = await import('../src/models/Submission.js') const { SubmissionCooldown } = await import('../src/models/SubmissionCooldown.js') const { default: mongoose } = await import('mongoose') @@ -284,6 +285,34 @@ let submission describe('submissions and the cooldown rule', () => { const good = () => ({ videoId: video1.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) + test('archive and database failures release cooldown and remove any archived file', async () => { + const originalArchive = storageService.archive + const originalCreate = submissionRepo.create + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const cooldownFilter = { user: otherId, video: video1.id } + let failedRecording + try { + storageService.archive = async () => { throw new Error('archive unavailable') } + const archiveFailure = await other.upload('/submissions', recordingForm(good())) + assert.equal(archiveFailure.status, 500) + assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) + + storageService.archive = originalArchive + submissionRepo.create = async (data) => { + failedRecording = data.recording + throw new Error('database unavailable') + } + const databaseFailure = await other.upload('/submissions', recordingForm(good())) + assert.equal(databaseFailure.status, 500) + assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) + await assert.rejects(() => storageService.describe(failedRecording)) + assert.deepEqual(filesIn('tmp'), []) + } finally { + storageService.archive = originalArchive + submissionRepo.create = originalCreate + } + }) + test('input is checked before anything is stored', async () => { const before = filesIn('recordings').length assert.equal((await new Client(base).upload('/submissions', recordingForm(good()))).status, 401) diff --git a/backend/tests/helpers.js b/backend/tests/helpers.js index 5f9e839..9cc47cc 100644 --- a/backend/tests/helpers.js +++ b/backend/tests/helpers.js @@ -69,5 +69,9 @@ export const recordingForm = (fields, { file = webm(3000), type = 'video/webm' } export const filesIn = (folder) => { const dir = path.join(uploadDir, folder) - return fs.existsSync(dir) ? fs.readdirSync(dir) : [] + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const relative = path.join(folder, entry.name) + return entry.isDirectory() ? filesIn(relative) : [relative] + }) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1411a79..02975bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", diff --git a/frontend/package.json b/frontend/package.json index 1611f8f..90de74f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "engines": { "node": ">=20.19.0" diff --git a/frontend/src/context/LibraryProvider.jsx b/frontend/src/context/LibraryProvider.jsx index ab79164..2efb425 100644 --- a/frontend/src/context/LibraryProvider.jsx +++ b/frontend/src/context/LibraryProvider.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, PageState } from '../components/ui' import { api } from '../services/api' import { useAuth } from './AuthContext' import { LibraryContext } from './LibraryContext' @@ -6,32 +7,39 @@ import { LibraryContext } from './LibraryContext' const EMPTY = { ready: false, categories: [], videos: [], submissions: [] } const SUBMISSION_COOLDOWN_MS = Number(import.meta.env.VITE_SUBMISSION_COOLDOWN_MS || 30_000) +function loadLibrary(user) { + const includeAll = user?.role === 'admin' + return Promise.all([ + api.categories.list(), + api.videos.list(), + user ? api.submissions.list(user.id, includeAll) : Promise.resolve([]), + ]) +} + export default function LibraryProvider({ children }) { const { user } = useAuth() const [data, setData] = useState(EMPTY) + const [loadError, setLoadError] = useState('') + const [loadAttempt, setLoadAttempt] = useState(0) const refresh = useCallback(async () => { - const includeAll = user?.role === 'admin' - const [categories, videos, submissions] = await Promise.all([ - api.categories.list(), - api.videos.list(), - user ? api.submissions.list(user.id, includeAll) : Promise.resolve([]), - ]) + const [categories, videos, submissions] = await loadLibrary(user) setData({ ready: true, categories, videos, submissions }) + setLoadError('') }, [user]) useEffect(() => { let active = true - const includeAll = user?.role === 'admin' - Promise.all([ - api.categories.list(), - api.videos.list(), - user ? api.submissions.list(user.id, includeAll) : Promise.resolve([]), - ]).then(([categories, videos, submissions]) => { - if (active) setData({ ready: true, categories, videos, submissions }) - }) + setLoadError('') + loadLibrary(user) + .then(([categories, videos, submissions]) => { + if (active) setData({ ready: true, categories, videos, submissions }) + }) + .catch((error) => { + if (active) setLoadError(error?.message || 'Unable to load the library. Try again.') + }) return () => { active = false } - }, [user]) + }, [user, loadAttempt]) const value = useMemo(() => { const { categories, videos, submissions } = data @@ -71,7 +79,12 @@ export default function LibraryProvider({ children }) { videoId: video.id, recording: recording.blob, trimStart: recording.edit.start, trimEnd: recording.edit.end, mirrored: recording.edit.mirrored, duration: recording.duration, size: recording.blob.size, mimeType: recording.mimeType, }) - await refresh() + void refresh().catch(() => { + setData((current) => ({ + ...current, + submissions: [submission, ...current.submissions.filter((item) => item.id !== submission.id)], + })) + }) return submission }, async addCategory(values) { const created = await api.categories.create(values); await refresh(); return created }, @@ -83,6 +96,13 @@ export default function LibraryProvider({ children }) { } }, [data, user, refresh]) + if (!data.ready && loadError) { + return setLoadAttempt((attempt) => attempt + 1)}>Try again} + >{loadError} + } if (!data.ready) return
Loading your library…
return {children} } diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index cf01c96..f59d32b 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -185,9 +185,9 @@ export default function Landing() {

Have questions about video contributions, recording specifications, or partnership opportunities? Reach out to our team.

- + {/* contact@pslai.org - + */} diff --git a/frontend/src/pages/RecordingEditor.jsx b/frontend/src/pages/RecordingEditor.jsx index e68af35..ca12597 100644 --- a/frontend/src/pages/RecordingEditor.jsx +++ b/frontend/src/pages/RecordingEditor.jsx @@ -32,7 +32,7 @@ function EditScreen({ videoId }) { onSubmit={async () => { await submitRecording(video, recording) removeRecording(video.id) - await notify({ type: 'submission', title: 'Recording submitted', body: video.title, href: watchUrl(video.id) }) + await notify({ type: 'submission', title: 'Recording submitted', body: video.title, href: watchUrl(video.id) }).catch(() => {}) navigate(watchUrl(video.id), { state: { justSubmitted: true } }) }} /> From 0d49235d2f4a81c160e4c2c34f014372366b3e2d Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 12:11:32 +0500 Subject: [PATCH 2/6] fix: initial library failure --- frontend/src/context/LibraryProvider.jsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/context/LibraryProvider.jsx b/frontend/src/context/LibraryProvider.jsx index 2efb425..acbedc7 100644 --- a/frontend/src/context/LibraryProvider.jsx +++ b/frontend/src/context/LibraryProvider.jsx @@ -30,10 +30,12 @@ export default function LibraryProvider({ children }) { useEffect(() => { let active = true - setLoadError('') loadLibrary(user) .then(([categories, videos, submissions]) => { - if (active) setData({ ready: true, categories, videos, submissions }) + if (active) { + setData({ ready: true, categories, videos, submissions }) + setLoadError('') + } }) .catch((error) => { if (active) setLoadError(error?.message || 'Unable to load the library. Try again.') @@ -100,7 +102,10 @@ export default function LibraryProvider({ children }) { return setLoadAttempt((attempt) => attempt + 1)}>Try again} + action={} >{loadError} } if (!data.ready) return
Loading your library…
From 91c13ce8f55386b6aa97a0ec2479311c5c3fca20 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 12:15:28 +0500 Subject: [PATCH 3/6] feat: demo video ops --- backend/docs/API.md | 4 + .../src/controllers/demoVideoController.js | 11 ++ backend/src/docs/openapi.js | 7 + backend/src/middlewares/upload.js | 3 + .../validators/demoVideoSchemas.js | 6 + backend/src/models/DemoVideo.js | 22 ++++ backend/src/repositories/demoVideoRepo.js | 13 ++ backend/src/routes/demoVideoRoutes.js | 19 +++ backend/src/routes/index.js | 2 + backend/src/services/demoVideoService.js | 49 +++++++ backend/tests/api.test.js | 30 +++++ frontend/src/pages/Demo.jsx | 8 +- frontend/src/pages/admin/AdminDemoVideo.jsx | 120 ++++++++++++++++++ frontend/src/pages/admin/AdminVideos.jsx | 2 + frontend/src/services/api.js | 12 ++ frontend/src/services/apiRoutes.js | 7 + 16 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 backend/src/controllers/demoVideoController.js create mode 100644 backend/src/middlewares/validators/demoVideoSchemas.js create mode 100644 backend/src/models/DemoVideo.js create mode 100644 backend/src/repositories/demoVideoRepo.js create mode 100644 backend/src/routes/demoVideoRoutes.js create mode 100644 backend/src/services/demoVideoService.js create mode 100644 frontend/src/pages/admin/AdminDemoVideo.jsx diff --git a/backend/docs/API.md b/backend/docs/API.md index 5dccae1..0d0297b 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -54,6 +54,8 @@ Errors use a structured object suitable for form-level and field-level messages: | GET | `/videos/:id/file` | Public when published | Stream a published reference video. | | GET | `/videos/:id/poster` | Public when published | Stream a published poster. | | POST/PATCH/DELETE | `/videos`, `/videos/:id` | Admin | Upload and manage base videos. | +| GET | `/demo-video`, `/demo-video/file` | Public | Read metadata or stream the current walkthrough video. | +| POST/DELETE | `/demo-video` | Admin | Upload/replace or delete the walkthrough video. | | GET/POST | `/submissions` | User | List or create contributor recordings. | | GET | `/submissions/:id/recording` | None | Deliberately unavailable; submission archives are append-only. | | POST | `/contact` | Public | Send a contact message. | @@ -65,6 +67,8 @@ Errors use a structured object suitable for form-level and field-level messages: Video creation uses `multipart/form-data` with `video`, optional `poster`, `title`, optional `categoryId`, `status`, and `durationSec`. Submission creation uses `multipart/form-data` with `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration`. +The public demo video is a separate singleton asset, independent of the lesson library. Admin uploads use `multipart/form-data` with `video`, `title`, and optional `durationSec`; uploading replaces the current demo asset. + The server validates file signatures, size limits, metadata, and route IDs. Client-reported durations are currently accepted for trimming metadata; server-side media probing and trimming are future work. ## Visibility rules diff --git a/backend/src/controllers/demoVideoController.js b/backend/src/controllers/demoVideoController.js new file mode 100644 index 0000000..149cffe --- /dev/null +++ b/backend/src/controllers/demoVideoController.js @@ -0,0 +1,11 @@ +import { demoVideoService } from '../services/demoVideoService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' +import { sendFile } from '../utils/sendFile.js' + +export const demoVideoController = { + get: asyncHandler(async (req, res) => ok(res, await demoVideoService.get())), + upload: asyncHandler(async (req, res) => created(res, await demoVideoService.upload(req.body, req.file))), + remove: asyncHandler(async (req, res) => { await demoVideoService.remove(); noContent(res) }), + file: asyncHandler(async (req, res) => sendFile(req, res, await demoVideoService.getFile(), { cache: 'no-cache' })), +} diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index d751a16..ecabdc5 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -47,6 +47,7 @@ export const openapi = { { name: 'Users' }, { name: 'Categories' }, { name: 'Videos' }, + { name: 'Demo Video' }, { name: 'Submissions' }, { name: 'Contact' }, { name: 'Admin' }, @@ -130,6 +131,12 @@ export const openapi = { }, '/videos/{id}/file': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video file', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, '/videos/{id}/poster': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video poster', responses: { 200: { description: 'Image stream' }, 404: errorResponse } } }, + '/demo-video': { + get: { tags: ['Demo Video'], summary: 'Get the public demo video metadata', responses: { 200: { description: 'Current demo video, or null when none is set' } } }, + post: { tags: ['Demo Video'], summary: 'Upload or replace the public demo video', security: adminSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['video', 'title'], properties: { video: { type: 'string', format: 'binary' }, title: { type: 'string', maxLength: 120 }, durationSec: { type: 'number' } } } } } }, responses: { 201: { description: 'Uploaded' }, 422: errorResponse } }, + delete: { tags: ['Demo Video'], summary: 'Delete the public demo video', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 403: errorResponse } }, + }, + '/demo-video/file': { get: { tags: ['Demo Video'], summary: 'Stream the public demo video', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, '/submissions': { get: { tags: ['Submissions'], summary: 'List the current user submissions', security: userSecurity, responses: { 200: { description: 'Submissions' }, 401: errorResponse } }, post: { tags: ['Submissions'], summary: 'Submit a recording', security: userSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['recording', 'videoId'], properties: { recording: { type: 'string', format: 'binary' }, videoId: { type: 'string' }, trimStart: { type: 'number' }, trimEnd: { type: 'number' }, mirrored: { type: 'boolean' }, duration: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js index 2246944..c40d53a 100644 --- a/backend/src/middlewares/upload.js +++ b/backend/src/middlewares/upload.js @@ -27,5 +27,8 @@ const build = ({ maxMb, rules, files }) => multer({ export const videoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/', poster: 'image/' }, files: 2 }) .fields([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }]) +/** Dedicated public demo video, managed separately from lesson videos. */ +export const demoVideoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/' }, files: 1 }).single('video') + /** A contributor's recording. */ export const recordingUpload = build({ maxMb: env.MAX_RECORDING_UPLOAD_MB, rules: { recording: 'video/' }, files: 1 }).single('recording') diff --git a/backend/src/middlewares/validators/demoVideoSchemas.js b/backend/src/middlewares/validators/demoVideoSchemas.js new file mode 100644 index 0000000..ddf9a9a --- /dev/null +++ b/backend/src/middlewares/validators/demoVideoSchemas.js @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const uploadDemoVideoSchema = z.object({ + title: z.string({ error: 'Give the demo video a title.' }).trim().min(1, 'Give the demo video a title.').max(120, 'Keep the title under 120 characters.'), + durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), +}) diff --git a/backend/src/models/DemoVideo.js b/backend/src/models/DemoVideo.js new file mode 100644 index 0000000..313c849 --- /dev/null +++ b/backend/src/models/DemoVideo.js @@ -0,0 +1,22 @@ +import mongoose from 'mongoose' +import { API_PREFIX } from '../config/constants.js' +import { jsonOptions } from '../utils/mongoose.js' +import { storedFileSchema } from './storedFile.js' + +const demoVideoSchema = new mongoose.Schema({ + _id: { type: String, default: 'current' }, + title: { type: String, required: true, trim: true, maxlength: 120 }, + durationSec: { type: Number, default: 0, min: 0 }, + videoFile: { type: storedFileSchema, required: true }, +}, { + timestamps: true, + toJSON: jsonOptions((doc, ret) => { + ret.videoUrl = `${API_PREFIX}/demo-video/file` + ret.size = ret.videoFile?.size ?? 0 + delete ret.videoFile + delete ret.updatedAt + return ret + }), +}) + +export const DemoVideo = mongoose.model('DemoVideo', demoVideoSchema) diff --git a/backend/src/repositories/demoVideoRepo.js b/backend/src/repositories/demoVideoRepo.js new file mode 100644 index 0000000..3be3dd8 --- /dev/null +++ b/backend/src/repositories/demoVideoRepo.js @@ -0,0 +1,13 @@ +import { DemoVideo } from '../models/DemoVideo.js' + +const CURRENT = 'current' + +export const demoVideoRepo = { + find: () => DemoVideo.findById(CURRENT), + replace: (data) => DemoVideo.findOneAndReplace( + { _id: CURRENT }, + { _id: CURRENT, ...data }, + { new: true, upsert: true, runValidators: true }, + ), + remove: () => DemoVideo.findByIdAndDelete(CURRENT), +} diff --git a/backend/src/routes/demoVideoRoutes.js b/backend/src/routes/demoVideoRoutes.js new file mode 100644 index 0000000..0638185 --- /dev/null +++ b/backend/src/routes/demoVideoRoutes.js @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { demoVideoController } from '../controllers/demoVideoController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { requireRole } from '../middlewares/requireRole.js' +import { uploadLimiter } from '../middlewares/rateLimiter.js' +import { validate } from '../middlewares/validate.js' +import { demoVideoUpload } from '../middlewares/upload.js' +import { uploadDemoVideoSchema } from '../middlewares/validators/demoVideoSchemas.js' + +const router = Router() +const adminOnly = [authenticate, requireRole(ROLES.ADMIN)] + +router.get('/', demoVideoController.get) +router.get('/file', demoVideoController.file) +router.post('/', ...adminOnly, uploadLimiter, demoVideoUpload, validate({ body: uploadDemoVideoSchema }), demoVideoController.upload) +router.delete('/', ...adminOnly, demoVideoController.remove) + +export default router diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index c1b3a00..ee3f7f2 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,6 +6,7 @@ import adminRoutes from './adminRoutes.js' import authRoutes from './authRoutes.js' import categoryRoutes from './categoryRoutes.js' import contactRoutes from './contactRoutes.js' +import demoVideoRoutes from './demoVideoRoutes.js' import submissionRoutes from './submissionRoutes.js' import userRoutes from './userRoutes.js' import videoRoutes from './videoRoutes.js' @@ -23,6 +24,7 @@ router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true } router.use('/auth', authRoutes) router.use('/users', userRoutes) router.use('/categories', categoryRoutes) +router.use('/demo-video', demoVideoRoutes) router.use('/videos', videoRoutes) router.use('/submissions', submissionRoutes) router.use('/contact', contactRoutes) diff --git a/backend/src/services/demoVideoService.js b/backend/src/services/demoVideoService.js new file mode 100644 index 0000000..fd05711 --- /dev/null +++ b/backend/src/services/demoVideoService.js @@ -0,0 +1,49 @@ +import { env } from '../config/env.js' +import { demoVideoRepo } from '../repositories/demoVideoRepo.js' +import { notFound, validationError } from '../utils/AppError.js' +import { sniffVideo } from '../utils/files.js' +import { logger } from '../utils/logger.js' +import { storageService } from './storage/index.js' + +export const demoVideoService = { + get: () => demoVideoRepo.find(), + + async upload(input, upload) { + if (!upload) throw validationError({ video: 'Choose a demo video to upload.' }) + const kind = await sniffVideo(upload.path) + if (!kind) throw validationError({ video: 'That does not look like an MP4, WebM or MOV video.' }) + + const videoFile = await storageService.save({ + tempPath: upload.path, + originalName: upload.originalname, + mimeType: kind.mimeType, + ext: kind.ext, + }, { folder: 'demo' }) + + try { + const current = await demoVideoRepo.find() + const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, videoFile }) + await storageService.remove(current?.videoFile) + return saved + } catch (error) { + await storageService.remove(videoFile) + throw error + } + }, + + async remove() { + const current = await demoVideoRepo.remove() + await storageService.remove(current?.videoFile) + }, + + async getFile() { + const current = await demoVideoRepo.find() + if (!current) throw notFound('No demo video is available.') + try { + return await storageService.describe(current.videoFile) + } catch (error) { + logger.error({ err: error, key: current.videoFile.key }, 'Demo video file could not be read') + throw notFound('The demo video file is missing.') + } + }, +} diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index 709d26d..2c9af86 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -84,6 +84,36 @@ describe('basics', () => { }) }) +describe('demo video', () => { + test('admins can upload, replace, and delete the public demo video', async () => { + const visitor = new Client(base) + assert.equal((await visitor.get('/demo-video')).body.data, null) + assert.equal((await contributor.upload('/demo-video', videoForm({ title: 'Not allowed' }))).status, 403) + + const first = await admin.upload('/demo-video', videoForm({ title: 'How to sign', durationSec: '8' })) + assert.equal(first.status, 201, JSON.stringify(first.body)) + assert.equal(first.body.data.title, 'How to sign') + assert.equal(first.body.data.videoUrl, '/api/v1/demo-video/file') + const firstFile = await visitor.get('/demo-video/file', { raw: true }) + assert.equal(firstFile.status, 200) + assert.deepEqual(firstFile.body, webm()) + assert.equal((await visitor.get('/videos')).body.data.some((video) => video.title === 'How to sign'), false) + + const replacement = await admin.upload('/demo-video', videoForm({ title: 'A new walkthrough' }, { video: mp4() })) + assert.equal(replacement.status, 201) + assert.equal(replacement.body.data.title, 'A new walkthrough') + assert.equal(filesIn('demo').length, 1) + const replacementFile = await visitor.get('/demo-video/file', { raw: true }) + assert.equal(replacementFile.headers.get('content-type'), 'video/mp4') + assert.deepEqual(replacementFile.body, mp4()) + + assert.equal((await admin.delete('/demo-video')).status, 204) + assert.equal((await visitor.get('/demo-video')).body.data, null) + assert.equal((await visitor.get('/demo-video/file')).status, 404) + assert.deepEqual(filesIn('demo'), []) + }) +}) + describe('auth', () => { test('signup validates every field', async () => { const res = await new Client(base).post('/auth/signup', {}) diff --git a/frontend/src/pages/Demo.jsx b/frontend/src/pages/Demo.jsx index a3185ad..ece8048 100644 --- a/frontend/src/pages/Demo.jsx +++ b/frontend/src/pages/Demo.jsx @@ -26,11 +26,11 @@ export default function Demo() { useEffect(() => { let active = true - api.videos.list() - .then((videos) => { + api.demoVideo.get() + .then((video) => { if (!active) return - setDemoVideo(videos[0] || null) - setVideoState(videos[0] ? 'ready' : 'empty') + setDemoVideo(video || null) + setVideoState(video ? 'ready' : 'empty') }) .catch(() => { if (active) setVideoState('error') diff --git a/frontend/src/pages/admin/AdminDemoVideo.jsx b/frontend/src/pages/admin/AdminDemoVideo.jsx new file mode 100644 index 0000000..699ae0a --- /dev/null +++ b/frontend/src/pages/admin/AdminDemoVideo.jsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react' +import Field from '../../components/Field' +import { Alert, Button } from '../../components/ui' +import { api } from '../../services/api' +import { formatBytes, formatTime } from '../../utils/format' +import { inspectVideoFile } from '../../utils/media' + +const titleFromFile = (name) => name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() + +export default function AdminDemoVideo() { + const [current, setCurrent] = useState(null) + const [file, setFile] = useState(null) + const [previewUrl, setPreviewUrl] = useState('') + const [title, setTitle] = useState('') + const [durationSec, setDurationSec] = useState(0) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const [message, setMessage] = useState(null) + + useEffect(() => { + let active = true + api.demoVideo.get() + .then((video) => { if (active) setCurrent(video) }) + .catch((error) => { if (active) setMessage({ tone: 'error', text: error.message || 'Could not load the demo video.' }) }) + .finally(() => { if (active) setLoading(false) }) + return () => { active = false } + }, []) + + useEffect(() => () => { + if (previewUrl) URL.revokeObjectURL(previewUrl) + }, [previewUrl]) + + const pick = async (event) => { + const selected = event.target.files?.[0] + setMessage(null) + if (!selected) { + setFile(null) + setPreviewUrl('') + return + } + if (!selected.type.startsWith('video/')) { + setFile(null) + setPreviewUrl('') + setMessage({ tone: 'error', text: 'Choose a video file (MP4, WebM or MOV).' }) + return + } + setFile(selected) + setPreviewUrl(URL.createObjectURL(selected)) + setTitle(titleFromFile(selected.name)) + setDurationSec((await inspectVideoFile(selected)).durationSec) + } + + const upload = async (event) => { + event.preventDefault() + if (!file || !title.trim()) return + setBusy(true) + setMessage(null) + try { + const saved = await api.demoVideo.upload({ file, title: title.trim(), durationSec }) + setCurrent(saved) + setFile(null) + setPreviewUrl('') + setMessage({ tone: 'success', text: 'Demo video uploaded and live on the public demo page.' }) + } catch (error) { + setMessage({ tone: 'error', text: error.message || 'The demo video could not be uploaded.' }) + } finally { + setBusy(false) + } + } + + const remove = async () => { + setBusy(true) + setMessage(null) + try { + await api.demoVideo.remove() + setCurrent(null) + setConfirmDelete(false) + setMessage({ tone: 'success', text: 'Demo video deleted from the public demo page.' }) + } catch (error) { + setMessage({ tone: 'error', text: error.message || 'The demo video could not be deleted.' }) + } finally { + setBusy(false) + } + } + + return
+
+

Public walkthrough

Demo page video.

+ {current && !confirmDelete && } +
+ + {loading + ?

Loading demo video…

+ : current + ?
+
+ :

No demo video is set. Upload one to show it on the public demo page.

} + +
+
+ {previewUrl + ?
+
+ setTitle(event.target.value)} placeholder="How to contribute" /> + {message && {message.text}} + {confirmDelete &&
+

Delete the current demo video? The public demo page will show an empty state until another is uploaded.

+
+
} + +
+
+
+} diff --git a/frontend/src/pages/admin/AdminVideos.jsx b/frontend/src/pages/admin/AdminVideos.jsx index 7f5177a..c47b9e5 100644 --- a/frontend/src/pages/admin/AdminVideos.jsx +++ b/frontend/src/pages/admin/AdminVideos.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import AdminDemoVideo from './AdminDemoVideo' import Field from '../../components/Field' import { Alert, Badge, Button, SectionHeading, VideoArtwork } from '../../components/ui' import { useLibrary } from '../../context/LibraryContext' @@ -44,6 +45,7 @@ export default function AdminVideos() { } return <> +
`${API_BASE}/videos/${id}`, }, + demoVideo: { + get: () => `${API_BASE}/demo-video`, + file: () => `${API_BASE}/demo-video/file`, + upload: () => `${API_BASE}/demo-video`, + remove: () => `${API_BASE}/demo-video`, + }, + submissions: { list: () => `${API_BASE}/submissions`, create: () => `${API_BASE}/submissions`, From 7545e71f0b5629a71c2f08d189459a047ff8d63b Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 12:30:31 +0500 Subject: [PATCH 4/6] feat: otp and email verification --- backend/.env.example | 9 ++ backend/README.md | 12 +- backend/docs/API.md | 10 +- backend/package-lock.json | 143 ++---------------- backend/package.json | 1 + backend/src/config/env.js | 12 ++ backend/src/controllers/authController.js | 27 +++- backend/src/docs/openapi.js | 16 +- backend/src/middlewares/authenticate.js | 5 +- .../src/middlewares/validators/authSchemas.js | 14 ++ backend/src/models/User.js | 15 +- backend/src/repositories/userRepo.js | 6 +- backend/src/routes/authRoutes.js | 6 +- backend/src/services/authService.js | 112 +++++++++++++- backend/src/services/external/sendOTPBrevo.js | 28 ++++ backend/src/utils/tokens.js | 7 +- backend/tests/api.test.js | 37 ++++- frontend/README.md | 3 + frontend/src/App.jsx | 4 + frontend/src/context/AuthProvider.jsx | 7 +- frontend/src/pages/ForgotPassword.jsx | 90 +++++++++++ frontend/src/pages/Login.jsx | 5 +- frontend/src/pages/Signup.jsx | 13 +- frontend/src/pages/VerifyEmail.jsx | 64 ++++++++ frontend/src/routes/appRoutes.js | 2 + frontend/src/services/api.js | 4 + frontend/src/services/apiRoutes.js | 4 + 27 files changed, 479 insertions(+), 177 deletions(-) create mode 100644 backend/src/services/external/sendOTPBrevo.js create mode 100644 frontend/src/pages/ForgotPassword.jsx create mode 100644 frontend/src/pages/VerifyEmail.jsx diff --git a/backend/.env.example b/backend/.env.example index b64233f..8718372 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,6 +21,15 @@ JWT_EXPIRES_DAYS=7 COOKIE_SAMESITE=lax BCRYPT_ROUNDS=12 +# Brevo transactional email. Verify this sender in your Brevo account. +BREVO_API_KEY= +BREVO_SENDER_EMAIL=auth@signpakcommons.org +BREVO_SENDER_NAME=SignPak Commons +# One-time verification/reset codes expire after this many minutes. +AUTH_OTP_TTL_MINUTES=5 +AUTH_OTP_RESEND_SECONDS=60 +AUTH_OTP_MAX_ATTEMPTS=5 + # The admin account is created (or promoted) from these on every start. ADMIN_EMAIL=admin@signpak.dev ADMIN_PASSWORD= diff --git a/backend/README.md b/backend/README.md index 3982722..0ff7c6b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,7 @@ Express + Node.js + MongoDB (Mongoose). Cookie-based auth, local base-video stor ```bash cd backend npm install -cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD and MONGODB_URI +cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD, MONGODB_URI and Brevo sender credentials npm run dev # http://localhost:5000, health check at /api/v1/health npm test # integration tests (see "Testing") ``` @@ -52,8 +52,12 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, | Method + path | Who | Notes | | --- | --- | --- | | `GET /health` | anyone | 503 if the database is down | -| `POST /auth/signup` | anyone | Always creates a contributor. Sets the cookie | +| `POST /auth/signup` | anyone | Creates an unverified contributor and emails a verification code | | `POST /auth/login` | anyone | Sets the cookie | +| `POST /auth/verify-email` | anyone | Verifies an email and sets the session cookie | +| `POST /auth/resend-verification` | anyone | Resends a verification code (generic response) | +| `POST /auth/forgot-password` | anyone | Sends a password-reset code (generic response) | +| `POST /auth/reset-password` | anyone | Resets password and invalidates existing sessions | | `POST /auth/logout` | anyone | Clears the cookie | | `GET /auth/session` | anyone | `{ data: user }`, or `{ data: null }` for visitors | | `GET /users/me` | logged in | | @@ -73,7 +77,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, ## Decisions worth knowing -**Auth.** A signed JWT in an `httpOnly` cookie (`signpak_token`), so page scripts cannot read it. The user is reloaded from the database on every request, so role changes and deletions apply immediately. Login answers identically for "wrong password" and "no such email", and does the same amount of hashing work for both. +**Auth.** A signed JWT in an `httpOnly` cookie (`signpak_token`), so page scripts cannot read it. Signup requires email verification before login; OTPs are hashed in MongoDB, expire, and have bounded attempts. Password reset uses the same one-time-code policy and increments the session version to invalidate existing cookies. Configure `BREVO_API_KEY` and a verified `BREVO_SENDER_EMAIL` for transactional email. The user is reloaded from the database on every request, so role changes and deletions apply immediately. Login answers identically for "wrong password" and "no such email", and does the same amount of hashing work for both. **CSRF.** `SameSite=Lax` plus an `Origin` check: any `POST/PATCH/DELETE` carrying an `Origin` header outside `CLIENT_ORIGIN` is refused with 403. @@ -91,7 +95,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, ## Testing -`npm test` runs `tests/api.test.js` (31 tests): auth, roles, uploads, Range streaming, the cooldown rule (including a real elapsed-cooldown resubmission, sped up via `SUBMISSION_COOLDOWN_MS=200` in `tests/helpers.js`), stats, contact, cleanup of temp files. It calls the real HTTP stack. +`npm test` runs the HTTP integration suite for auth, roles, uploads, Range streaming, the cooldown rule, stats, contact, and cleanup of temporary files. Authentication email delivery is stubbed in tests; the application mail path uses Brevo. - Set `MONGODB_URI_TEST` to run against a MongoDB you already have (it uses that database and **drops it**, so point it at a throwaway one). - Otherwise it starts `mongodb-memory-server`, which downloads a MongoDB binary on first use. diff --git a/backend/docs/API.md b/backend/docs/API.md index 0d0297b..3e87fc7 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -12,10 +12,12 @@ The OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openap ## Authentication -Signup and login set an HTTP-only `signpak_token` cookie. Browser clients must send requests with credentials enabled. Swagger UI can describe the cookie scheme, but browser authentication is normally established by calling `/auth/signup` or `/auth/login` first. +Login and successful email verification set an HTTP-only `signpak_token` cookie. Signup creates an unverified account and emails a six-digit code; browser clients must verify it before logging in. Browser requests must send credentials. The cookie is cleared by `POST /auth/logout`. `GET /auth/session` is public and returns the current user when a valid session exists, or `null` for a visitor. +Verification and password reset codes are stored as keyed hashes, expire after `AUTH_OTP_TTL_MINUTES`, and have bounded attempts and resend intervals. Password reset responses do not reveal whether an email exists. Configure `BREVO_API_KEY`, `BREVO_SENDER_EMAIL` (a verified Brevo sender), and optionally `BREVO_SENDER_NAME` in the backend environment. + ## Response format JSON success responses use: @@ -43,8 +45,12 @@ Errors use a structured object suitable for form-level and field-level messages: | Method | Path | Access | Purpose | | --- | --- | --- | --- | | GET | `/health` | Public | Check API and database status. | -| POST | `/auth/signup` | Public | Create a contributor account and session. | +| POST | `/auth/signup` | Public | Create an unverified contributor and send a verification code. | | POST | `/auth/login` | Public | Create a session. | +| POST | `/auth/verify-email` | Public | Verify a code and establish a session. | +| POST | `/auth/resend-verification` | Public | Resend a verification code with a generic response. | +| POST | `/auth/forgot-password` | Public | Request a password reset code with a generic response. | +| POST | `/auth/reset-password` | Public | Reset password and invalidate existing sessions. | | POST | `/auth/logout` | Public | Clear the session. | | GET | `/auth/session` | Public | Read the current session. | | GET/PATCH | `/users/me` | User | Read or update the current profile. | diff --git a/backend/package-lock.json b/backend/package-lock.json index 57bdb30..e5e29f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "license": "MIT", "dependencies": { + "@getbrevo/brevo": "^6.0.3", "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", @@ -43,6 +44,14 @@ "node": ">=20" } }, + "node_modules/@getbrevo/brevo": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-6.0.3.tgz", + "integrity": "sha512-yWBLKoHC8P5HLZ8z6iRuqDYLpCRKPTUETnZOLZzNDWGFYL78y4SHOTxQVrKH5ROX7f4q+SQ19LD3GWtJlIuNXA==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.4.tgz", @@ -1594,21 +1603,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -2011,21 +2005,6 @@ "@types/webidl-conversions": "*" } }, - "node_modules/mongodb-memory-server-core/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/mongodb-memory-server-core/node_modules/bson": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", @@ -2054,56 +2033,6 @@ } } }, - "node_modules/mongodb-memory-server-core/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/mongodb-memory-server-core/node_modules/mongodb": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", @@ -2169,60 +2098,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/mongodb-memory-server-core/node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1a20fec..86841e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "node": ">=20" }, "dependencies": { + "@getbrevo/brevo": "^6.0.3", "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 20013c0..464efe0 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -19,6 +19,12 @@ const schema = z.object({ COOKIE_SECURE: bool.optional(), COOKIE_SAMESITE: z.enum(['lax', 'strict', 'none']).default('lax'), BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12), + BREVO_API_KEY: z.string().optional(), + BREVO_SENDER_EMAIL: z.email().optional(), + BREVO_SENDER_NAME: z.string().min(1).default('SignPak Commons'), + AUTH_OTP_TTL_MINUTES: z.coerce.number().int().min(1).max(30).default(5), + AUTH_OTP_RESEND_SECONDS: z.coerce.number().int().min(0).max(3600).default(60), + AUTH_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5), ADMIN_EMAIL: z.email({ error: 'ADMIN_EMAIL is required and must be a valid email' }), ADMIN_PASSWORD: z.string({ error: 'ADMIN_PASSWORD is required (at least 8 characters)' }).min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72), @@ -40,6 +46,12 @@ const schema = z.object({ // (like BCRYPT_ROUNDS) so tests do not have to wait 30 real seconds. SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), }).superRefine((data, context) => { + if (data.NODE_ENV === 'production' && !data.BREVO_API_KEY) { + context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' }) + } + if (data.NODE_ENV === 'production' && !data.BREVO_SENDER_EMAIL) { + context.addIssue({ code: 'custom', path: ['BREVO_SENDER_EMAIL'], message: 'Required in production; use a verified Brevo sender address.' }) + } const needsGoogle = data.STORAGE_DRIVER === 'gdrive' || data.ARCHIVE_STORAGE_DRIVER === 'gdrive' if (needsGoogle && !data.GOOGLE_SERVICE_ACCOUNT_JSON) { context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' }) diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 440ed46..d1fbcdb 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -5,10 +5,7 @@ import { clearAuthCookie, setAuthCookie } from '../utils/tokens.js' export const authController = { signup: asyncHandler(async (req, res) => { - const { user, token } = await authService.register(req.body) - await authService.recordDevice(user, req.get('X-Device-ID')) - setAuthCookie(res, token) - created(res, user) + created(res, await authService.register(req.body)) }), login: asyncHandler(async (req, res) => { @@ -18,6 +15,28 @@ export const authController = { ok(res, user) }), + verifyEmail: asyncHandler(async (req, res) => { + const { user, token } = await authService.verifyEmail(req.body) + await authService.recordDevice(user, req.get('X-Device-ID')) + setAuthCookie(res, token) + ok(res, user) + }), + + resendVerification: asyncHandler(async (req, res) => { + await authService.resendVerification(req.body.email) + ok(res, { message: 'If the account needs verification, a code will be sent.' }) + }), + + forgotPassword: asyncHandler(async (req, res) => { + await authService.requestPasswordReset(req.body.email) + ok(res, { message: 'If an account exists for this email, a reset code will be sent.' }) + }), + + resetPassword: asyncHandler(async (req, res) => { + await authService.resetPassword(req.body) + ok(res, { message: 'Password reset. You can now log in.' }) + }), + logout: (req, res) => { clearAuthCookie(res) noContent(res) diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index ecabdc5..4ce3846 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -54,7 +54,7 @@ export const openapi = { ], components: { securitySchemes: { - cookieAuth: { type: 'apiKey', in: 'cookie', name: 'token', description: 'HTTP-only JWT cookie set by login or signup.' }, + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'signpak_token', description: 'HTTP-only JWT cookie set by login or email verification.' }, }, schemas: { Category: { @@ -75,10 +75,18 @@ export const openapi = { type: 'object', properties: { id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' }, + emailVerified: { type: 'boolean' }, role: { type: 'string', enum: ['user', 'admin'] }, status: { type: 'string', enum: ['active', 'suspended'] }, statusReason: { type: 'string' }, connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, }, }, + SignupInput: { + type: 'object', required: ['firstName', 'surname', 'email', 'password'], + properties: { + firstName: { type: 'string', maxLength: 60 }, surname: { type: 'string', maxLength: 60 }, + email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string' }, + }, + }, AuthInput: { type: 'object', required: ['email', 'password'], properties: { @@ -99,11 +107,15 @@ export const openapi = { }, '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } } }, '/auth/signup': { - post: { tags: ['Auth'], summary: 'Create an account', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 201: { description: 'Account created' }, 400: errorResponse } }, + post: { tags: ['Auth'], summary: 'Create an unverified account and send a code', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/SignupInput' } } } }, responses: { 201: { description: 'Account pending email verification' }, 400: errorResponse } }, }, '/auth/login': { post: { tags: ['Auth'], summary: 'Log in and set the HTTP-only cookie', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 200: { description: 'Logged in' }, 401: errorResponse } }, }, + '/auth/verify-email': { post: { tags: ['Auth'], summary: 'Verify an email code and establish a session', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email', 'code'], properties: { email: { type: 'string', format: 'email' }, code: { type: 'string', pattern: '^\\d{6}$' } } } } } }, responses: { 200: { description: 'Verified and logged in' }, 400: errorResponse } } }, + '/auth/resend-verification': { post: { tags: ['Auth'], summary: 'Resend an email verification code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: { description: 'Generic response' } } } }, + '/auth/forgot-password': { post: { tags: ['Auth'], summary: 'Request a password reset code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: { description: 'Generic response' } } } }, + '/auth/reset-password': { post: { tags: ['Auth'], summary: 'Reset a password using a one-time code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email', 'code', 'password', 'confirmPassword'], properties: { email: { type: 'string', format: 'email' }, code: { type: 'string', pattern: '^\\d{6}$' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string', format: 'password' } } } } } }, responses: { 200: { description: 'Password reset' }, 400: errorResponse } } }, '/auth/logout': { post: { tags: ['Auth'], summary: 'Clear the session cookie', responses: { 204: { description: 'Logged out' } } } }, '/auth/session': { get: { tags: ['Auth'], summary: 'Get the current session', responses: { 200: { description: 'Session state' } } } }, '/users/me': { diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js index 9e3b9f6..ab53f3b 100644 --- a/backend/src/middlewares/authenticate.js +++ b/backend/src/middlewares/authenticate.js @@ -6,9 +6,10 @@ import { verifyToken } from '../utils/tokens.js' async function loadUser(req) { const token = req.cookies?.[AUTH_COOKIE] - const userId = token && verifyToken(token) + const session = token && verifyToken(token) // The user is re-read on every request, so a deleted account or changed role takes effect immediately. - return userId ? userRepo.findById(userId) : null + const user = session ? await userRepo.findById(session.userId) : null + return user && (user.authVersion || 0) === session.authVersion ? user : null } /** Requires a valid session. Sets req.user. */ diff --git a/backend/src/middlewares/validators/authSchemas.js b/backend/src/middlewares/validators/authSchemas.js index 8619394..fd1313b 100644 --- a/backend/src/middlewares/validators/authSchemas.js +++ b/backend/src/middlewares/validators/authSchemas.js @@ -16,3 +16,17 @@ export const loginSchema = z.object({ email: emailField, password: z.string({ error: 'Enter your password.' }).min(1, 'Enter your password.').max(200), }) + +export const emailOnlySchema = z.object({ email: emailField }) + +export const verifyEmailSchema = z.object({ + email: emailField, + code: z.string().regex(/^\d{6}$/, 'Enter the six-digit code from your email.'), +}) + +export const resetPasswordSchema = z.object({ + email: emailField, + code: z.string().regex(/^\d{6}$/, 'Enter the six-digit code from your email.'), + password: z.string({ error: 'Create a password.' }).min(8, 'Use at least 8 characters.').max(72, 'Use 72 characters or fewer.'), + confirmPassword: z.string(), +}).refine((data) => data.confirmPassword === data.password, { path: ['confirmPassword'], message: 'Passwords do not match.' }) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 4903105..50f3668 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -7,6 +7,16 @@ const userSchema = new mongoose.Schema({ firstName: { type: String, required: true, trim: true, maxlength: 60 }, surname: { type: String, required: true, trim: true, maxlength: 60 }, passwordHash: { type: String, required: true, select: false }, + authVersion: { type: Number, default: 0 }, + emailVerified: { type: Boolean, default: false }, + emailVerificationCodeHash: { type: String, default: null, select: false }, + emailVerificationExpiresAt: { type: Date, default: null, select: false }, + emailVerificationAttempts: { type: Number, default: 0, select: false }, + emailVerificationSentAt: { type: Date, default: null, select: false }, + passwordResetCodeHash: { type: String, default: null, select: false }, + passwordResetExpiresAt: { type: Date, default: null, select: false }, + passwordResetAttempts: { type: Number, default: 0, select: false }, + passwordResetSentAt: { type: Date, default: null, select: false }, role: { type: String, enum: ROLE_LIST, default: ROLES.USER }, status: { type: String, enum: ['active', 'suspended'], default: 'active' }, statusReason: { type: String, trim: true, maxlength: 500, default: '' }, @@ -17,7 +27,10 @@ const userSchema = new mongoose.Schema({ }, }, { timestamps: true, - toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; delete ret.lastDeviceId; return ret }), + toJSON: jsonOptions((doc, ret) => { + for (const key of ['passwordHash', 'authVersion', 'updatedAt', 'lastDeviceId', 'emailVerificationCodeHash', 'emailVerificationExpiresAt', 'emailVerificationAttempts', 'emailVerificationSentAt', 'passwordResetCodeHash', 'passwordResetExpiresAt', 'passwordResetAttempts', 'passwordResetSentAt']) delete ret[key] + return ret + }), }) export const User = mongoose.model('User', userSchema) diff --git a/backend/src/repositories/userRepo.js b/backend/src/repositories/userRepo.js index f38e091..b86a3e4 100644 --- a/backend/src/repositories/userRepo.js +++ b/backend/src/repositories/userRepo.js @@ -2,9 +2,11 @@ import { User } from '../models/User.js' export const userRepo = { findById: (id) => User.findById(id), - findByEmail: (email, { withPassword = false } = {}) => { + findByEmail: (email, { withPassword = false, withOtp = false } = {}) => { const query = User.findOne({ email: email.trim().toLowerCase() }) - return withPassword ? query.select('+passwordHash') : query + if (withPassword) query.select('+passwordHash') + if (withOtp) query.select('+emailVerificationCodeHash +emailVerificationExpiresAt +emailVerificationAttempts +emailVerificationSentAt +passwordResetCodeHash +passwordResetExpiresAt +passwordResetAttempts +passwordResetSentAt') + return query }, findManyByIds: (ids) => User.find({ _id: { $in: ids } }), create: (data) => User.create(data), diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 504c2ff..d9fecf9 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -3,12 +3,16 @@ import { authController } from '../controllers/authController.js' import { optionalAuthenticate } from '../middlewares/authenticate.js' import { authLimiter } from '../middlewares/rateLimiter.js' import { validate } from '../middlewares/validate.js' -import { loginSchema, signupSchema } from '../middlewares/validators/authSchemas.js' +import { emailOnlySchema, loginSchema, resetPasswordSchema, signupSchema, verifyEmailSchema } from '../middlewares/validators/authSchemas.js' const router = Router() router.post('/signup', authLimiter, validate({ body: signupSchema }), authController.signup) router.post('/login', authLimiter, validate({ body: loginSchema }), authController.login) +router.post('/verify-email', authLimiter, validate({ body: verifyEmailSchema }), authController.verifyEmail) +router.post('/resend-verification', authLimiter, validate({ body: emailOnlySchema }), authController.resendVerification) +router.post('/forgot-password', authLimiter, validate({ body: emailOnlySchema }), authController.forgotPassword) +router.post('/reset-password', authLimiter, validate({ body: resetPasswordSchema }), authController.resetPassword) router.post('/logout', authController.logout) router.get('/session', optionalAuthenticate, authController.session) diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index 5312ce4..a4f8e70 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -1,37 +1,134 @@ import bcrypt from 'bcrypt' +import { createHmac, randomInt, timingSafeEqual } from 'node:crypto' import { ROLES } from '../config/constants.js' import { env } from '../config/env.js' import { userRepo } from '../repositories/userRepo.js' -import { conflict, unauthorized } from '../utils/AppError.js' +import { badRequest, conflict, unauthorized } from '../utils/AppError.js' import { logger } from '../utils/logger.js' import { isDuplicateKeyError } from '../utils/mongoose.js' import { signToken } from '../utils/tokens.js' +import { sendOTPEmailBrevo } from './external/sendOTPBrevo.js' // Compared against when the email is unknown, so "no such user" takes as long as "wrong password". const DUMMY_HASH = bcrypt.hashSync('signpak-timing-guard', env.BCRYPT_ROUNDS) const emailTaken = () => conflict('An account with this email already exists. Log in instead.', { email: 'An account with this email already exists.' }) +const invalidCode = () => badRequest('That code is invalid or has expired. Request a new code and try again.') +const otpHash = (purpose, userId, otp) => createHmac('sha256', env.JWT_SECRET).update(`${purpose}:${userId}:${otp}`).digest('hex') +const clearOtp = (user, purpose) => { + const prefix = purpose === 'email-verification' ? 'emailVerification' : 'passwordReset' + user[`${prefix}CodeHash`] = null + user[`${prefix}ExpiresAt`] = null + user[`${prefix}Attempts`] = 0 +} +const matchesOtp = (stored, supplied, purpose, userId) => { + if (!stored) return false + const expected = Buffer.from(stored, 'hex') + const actual = Buffer.from(otpHash(purpose, userId, supplied), 'hex') + return expected.length === actual.length && timingSafeEqual(expected, actual) +} export const authService = { async register({ email, firstName, surname, password }) { if (await userRepo.findByEmail(email)) throw emailTaken() const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) try { - // `role` is never read from the request: everyone who signs up is a contributor. const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER }) - return { user, token: signToken(user.id) } + const verificationEmailSent = await this.issueOtp(user, 'email-verification') + return { email: user.email, verificationEmailSent } } catch (error) { if (isDuplicateKeyError(error)) throw emailTaken() // two signups racing for the same email throw error } }, + async issueOtp(user, purpose) { + const prefix = purpose === 'email-verification' ? 'emailVerification' : 'passwordReset' + const otp = String(100000 + randomInt(900000)) + user[`${prefix}CodeHash`] = otpHash(purpose, user._id, otp) + user[`${prefix}ExpiresAt`] = new Date(Date.now() + env.AUTH_OTP_TTL_MINUTES * 60_000) + user[`${prefix}Attempts`] = 0 + user[`${prefix}SentAt`] = new Date() + await userRepo.save(user) + try { + await this.sendOTPEmail(user.email, otp, purpose) + return true + } catch (error) { + logger.error({ err: error, purpose }, 'Could not deliver authentication code') + clearOtp(user, purpose) + user[`${prefix}SentAt`] = null + await userRepo.save(user) + return false + } + }, + + sendOTPEmail: sendOTPEmailBrevo, + + async verifyEmail({ email, code }) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user || user.emailVerified) throw invalidCode() + const expiry = user.emailVerificationExpiresAt?.getTime() || 0 + if (!user.emailVerificationCodeHash || expiry < Date.now() || user.emailVerificationAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) { + clearOtp(user, 'email-verification') + await userRepo.save(user) + throw invalidCode() + } + if (!matchesOtp(user.emailVerificationCodeHash, code, 'email-verification', user._id)) { + user.emailVerificationAttempts += 1 + if (user.emailVerificationAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) clearOtp(user, 'email-verification') + await userRepo.save(user) + throw invalidCode() + } + user.emailVerified = true + clearOtp(user, 'email-verification') + await userRepo.save(user) + return { user, token: signToken(user.id, user.authVersion || 0) } + }, + + async resendVerification(email) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user || user.emailVerified) return false + const sentAt = user.emailVerificationSentAt?.getTime() || 0 + if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false + return this.issueOtp(user, 'email-verification') + }, + + async requestPasswordReset(email) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user) return false + const sentAt = user.passwordResetSentAt?.getTime() || 0 + if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false + return this.issueOtp(user, 'password-reset') + }, + + async resetPassword({ email, code, password }) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user) throw invalidCode() + const expiry = user.passwordResetExpiresAt?.getTime() || 0 + if (!user.passwordResetCodeHash || expiry < Date.now() || user.passwordResetAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) { + clearOtp(user, 'password-reset') + await userRepo.save(user) + throw invalidCode() + } + if (!matchesOtp(user.passwordResetCodeHash, code, 'password-reset', user._id)) { + user.passwordResetAttempts += 1 + if (user.passwordResetAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) clearOtp(user, 'password-reset') + await userRepo.save(user) + throw invalidCode() + } + user.passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) + user.authVersion = (user.authVersion || 0) + 1 + clearOtp(user, 'password-reset') + await userRepo.save(user) + }, + async login({ email, password }) { const user = await userRepo.findByEmail(email, { withPassword: true }) const matches = await bcrypt.compare(password, user?.passwordHash || DUMMY_HASH) if (!user || !matches) throw unauthorized('That email and password do not match. Check them and try again.') if (user.status === 'suspended') throw unauthorized('This account is suspended.') - return { user, token: signToken(user.id) } + if (!user.emailVerified) throw unauthorized('Verify your email address before logging in.') + return { user, token: signToken(user.id, user.authVersion || 0) } }, async recordDevice(user, deviceId) { @@ -45,12 +142,13 @@ export const authService = { const existing = await userRepo.findByEmail(env.ADMIN_EMAIL) if (!existing) { const passwordHash = await bcrypt.hash(env.ADMIN_PASSWORD, env.BCRYPT_ROUNDS) - await userRepo.create({ email: env.ADMIN_EMAIL, firstName: env.ADMIN_FIRST_NAME, surname: env.ADMIN_SURNAME, passwordHash, role: ROLES.ADMIN }) + await userRepo.create({ email: env.ADMIN_EMAIL, firstName: env.ADMIN_FIRST_NAME, surname: env.ADMIN_SURNAME, passwordHash, role: ROLES.ADMIN, emailVerified: true }) logger.info({ email: env.ADMIN_EMAIL }, 'Admin account created from environment') - } else if (existing.role !== ROLES.ADMIN) { + } else if (existing.role !== ROLES.ADMIN || !existing.emailVerified) { existing.role = ROLES.ADMIN + existing.emailVerified = true await userRepo.save(existing) - logger.info({ email: env.ADMIN_EMAIL }, 'Existing account promoted to admin from environment') + logger.info({ email: env.ADMIN_EMAIL }, 'Existing account promoted or verified as admin from environment') } // An existing admin's password is intentionally left alone; changing ADMIN_PASSWORD later does not reset it. }, diff --git a/backend/src/services/external/sendOTPBrevo.js b/backend/src/services/external/sendOTPBrevo.js new file mode 100644 index 0000000..67dbdfe --- /dev/null +++ b/backend/src/services/external/sendOTPBrevo.js @@ -0,0 +1,28 @@ +import { BrevoClient } from '@getbrevo/brevo'; +import { env } from '../../config/env.js' + +export async function sendOTPEmailBrevo(userEmail, otp, purpose) { + if (!env.BREVO_API_KEY) throw new Error('BREVO_API_KEY is not configured.') + if (!env.BREVO_SENDER_EMAIL) throw new Error('BREVO_SENDER_EMAIL is not configured.') + const brevo = new BrevoClient({ apiKey: env.BREVO_API_KEY }) + const isReset = purpose === 'password-reset' + const subject = isReset ? 'Reset your SignPak Commons password' : 'Verify your SignPak Commons email' + const action = isReset ? 'reset your password' : 'verify your email address' + try { + await brevo.transactionalEmails.sendTransacEmail({ + sender: { name: env.BREVO_SENDER_NAME, email: env.BREVO_SENDER_EMAIL }, + to: [{ email: userEmail }], + subject, + htmlContent: ` +
+

${isReset ? 'Password reset code' : 'Verify your email'}

+

Enter this code to ${action}. It expires in ${env.AUTH_OTP_TTL_MINUTES} minutes.

+

${otp}

+

If you didn't request this, you can ignore this email.

+
+ `, + }); + } catch (error) { + throw new Error('Could not send OTP email.', { cause: error }) + } +} diff --git a/backend/src/utils/tokens.js b/backend/src/utils/tokens.js index a9d478f..af8de5d 100644 --- a/backend/src/utils/tokens.js +++ b/backend/src/utils/tokens.js @@ -4,12 +4,13 @@ import { env } from '../config/env.js' const DAY_MS = 24 * 60 * 60 * 1000 -export const signToken = (userId) => jwt.sign({}, env.JWT_SECRET, { subject: String(userId), expiresIn: `${env.JWT_EXPIRES_DAYS}d`, algorithm: 'HS256' }) +export const signToken = (userId, authVersion = 0) => jwt.sign({ authVersion }, env.JWT_SECRET, { subject: String(userId), expiresIn: `${env.JWT_EXPIRES_DAYS}d`, algorithm: 'HS256' }) -/** Returns the user id, or null when the token is missing, expired or tampered with. */ +/** Returns the verified session claims, or null when the token is missing, expired or tampered with. */ export function verifyToken(token) { try { - return jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] }).sub || null + const payload = jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] }) + return payload.sub ? { userId: payload.sub, authVersion: payload.authVersion ?? 0 } : null } catch { return null } diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index 2c9af86..6252bf0 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -5,6 +5,7 @@ import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './he const { default: app } = await import('../src/app.js') const { connectDb, disconnectDb } = await import('../src/config/db.js') const { authService } = await import('../src/services/authService.js') +const deliveredCodes = new Map() const { storageService } = await import('../src/services/storage/index.js') const { submissionRepo } = await import('../src/repositories/submissionRepo.js') const { Submission } = await import('../src/models/Submission.js') @@ -12,10 +13,12 @@ const { SubmissionCooldown } = await import('../src/models/SubmissionCooldown.js const { default: mongoose } = await import('mongoose') let server, memory, base, admin, contributor, other +let eveSession const asJson = (res) => res.body const contributorData = { firstName: 'Sara', surname: 'Ahmed', email: 'Sara@Example.com', password: 'password1', confirmPassword: 'password1' } before(async () => { + authService.sendOTPEmail = async (email, code, purpose) => deliveredCodes.set(`${email.toLowerCase()}:${purpose}`, code) let uri = process.env.MONGODB_URI_TEST if (!uri) { const { MongoMemoryServer } = await import('mongodb-memory-server') @@ -34,8 +37,11 @@ before(async () => { assert.equal((await admin.post('/auth/login', { email: 'admin@signpak.test', password: 'Admin@12345' })).status, 200) contributor = new Client(base) assert.equal((await contributor.post('/auth/signup', contributorData)).status, 201) + assert.equal((await contributor.get('/users/me')).status, 401) + assert.equal((await contributor.post('/auth/verify-email', { email: contributorData.email, code: deliveredCodes.get('sara@example.com:email-verification') })).status, 200) other = new Client(base) await other.post('/auth/signup', { firstName: 'Omar', surname: 'Khan', email: 'omar@example.com', password: 'password1' }) + await other.post('/auth/verify-email', { email: 'omar@example.com', code: deliveredCodes.get('omar@example.com:email-verification') }) }) after(async () => { @@ -128,16 +134,41 @@ describe('auth', () => { test('signup sets an httpOnly cookie and never leaks the hash or accepts a role', async () => { const c = new Client(base) + eveSession = c const res = await c.post('/auth/signup', { firstName: 'Eve', surname: 'Hacker', email: 'eve@example.com', password: 'password1', role: 'admin' }) assert.equal(res.status, 201) - assert.equal(res.body.data.role, 'user') assert.equal(res.body.data.email, 'eve@example.com') + assert.equal(res.body.data.verificationEmailSent, true) + assert.equal(res.setCookie.length, 0) + assert.equal((await c.post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) + assert.equal((await c.post('/auth/verify-email', { email: 'eve@example.com', code: '000000' })).status, 400) + const verified = await c.post('/auth/verify-email', { email: 'eve@example.com', code: deliveredCodes.get('eve@example.com:email-verification') }) + assert.equal(verified.status, 200) + assert.equal(verified.body.data.role, 'user') + assert.equal(verified.body.data.emailVerified, true) assert.equal(res.body.data.passwordHash, undefined) - assert.match(res.setCookie[0], /HttpOnly/i) - assert.match(res.setCookie[0], /SameSite=Lax/i) + assert.match(verified.setCookie[0], /HttpOnly/i) + assert.match(verified.setCookie[0], /SameSite=Lax/i) assert.equal((await c.get('/admin/stats')).status, 403) }) + test('password reset codes are one-time and replace the old password', async () => { + const resetRequested = await new Client(base).post('/auth/forgot-password', { email: 'eve@example.com' }) + const unknownRequested = await new Client(base).post('/auth/forgot-password', { email: 'nobody@example.com' }) + assert.equal(resetRequested.status, 200) + assert.deepEqual(resetRequested.body, unknownRequested.body) + const code = deliveredCodes.get('eve@example.com:password-reset') + const wrong = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code: '000000', password: 'password2', confirmPassword: 'password2' }) + assert.equal(wrong.status, 400) + const reset = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password2', confirmPassword: 'password2' }) + assert.equal(reset.status, 200) + assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) + assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password2' })).status, 200) + assert.equal((await eveSession.get('/users/me')).status, 401) + const reused = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password3', confirmPassword: 'password3' }) + assert.equal(reused.status, 400) + }) + test('duplicate emails conflict, case-insensitively', async () => { const res = await new Client(base).post('/auth/signup', { ...contributorData, email: 'SARA@example.com' }) assert.equal(res.status, 409) diff --git a/frontend/README.md b/frontend/README.md index efdcd81..fb5da6f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,6 +19,8 @@ npm run build | `/` | public | Landing: hero, about, policy, contact | | `/demo` | public | "How it works" walkthrough video | | `/signup`, `/login` | logged out | Account forms | +| `/verify-email` | logged out | Verify signup code sent by email | +| `/forgot-password` | logged out | Request a code and reset password | | `/home` | learner | Categories | | `/library/:categoryId` | learner | Videos in a category (search, filter, sort) | | `/watch?v=` | learner | Player and recorder | @@ -53,6 +55,7 @@ Every server call goes through `src/services/api.js`. Requests use the `/api/v1` - Admin video uploads are stored by the backend; the browser does not keep the base library in IndexedDB. - Recording submissions upload the recorded `Blob` with trim metadata. The backend enforces the submission cooldown. - Notifications remain browser-local until a persisted backend notification API is added. +- Signup requires a Brevo-delivered email code before the API establishes a session; password recovery uses a separate one-time code. ## Theming diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9bd651f..0a35054 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import AdminVideos from './pages/admin/AdminVideos' import CategoryBrowser from './pages/CategoryBrowser' import Demo from './pages/Demo' import Faq from './pages/Faq' +import ForgotPassword from './pages/ForgotPassword' import Home from './pages/Home' import Landing from './pages/Landing' import Login from './pages/Login' @@ -18,6 +19,7 @@ import Player from './pages/Player' import ProfileDashboard from './pages/ProfileDashboard' import RecordingEditor from './pages/RecordingEditor' import Signup from './pages/Signup' +import VerifyEmail from './pages/VerifyEmail' export default function App() { return @@ -29,6 +31,8 @@ export default function App() { }> } /> } /> + } /> + } /> {/* Logged-in contributors */} diff --git a/frontend/src/context/AuthProvider.jsx b/frontend/src/context/AuthProvider.jsx index 3edf722..1b0e8e8 100644 --- a/frontend/src/context/AuthProvider.jsx +++ b/frontend/src/context/AuthProvider.jsx @@ -28,7 +28,8 @@ export default function AuthProvider({ children }) { }, [refreshSession]) const login = useCallback(async (email, password) => setUser(await api.auth.login(email, password)), []) - const signup = useCallback(async (values) => setUser(await api.auth.register(values)), []) + const signup = useCallback((values) => api.auth.register(values), []) + const verifyEmail = useCallback(async (email, code) => setUser(await api.auth.verifyEmail(email, code)), []) const logout = useCallback(async () => { try { await api.auth.logout() } finally { setUser(null) } }, []) @@ -37,8 +38,8 @@ export default function AuthProvider({ children }) { }, [user]) const value = useMemo( - () => ({ user, loading, isAdmin: user?.role === 'admin', login, signup, logout, saveConnections }), - [user, loading, login, signup, logout, saveConnections], + () => ({ user, loading, isAdmin: user?.role === 'admin', login, signup, verifyEmail, logout, saveConnections }), + [user, loading, login, signup, verifyEmail, logout, saveConnections], ) return {children} } diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..2c4ce0b --- /dev/null +++ b/frontend/src/pages/ForgotPassword.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' +import AuthShell from '../components/AuthShell' +import Field from '../components/Field' +import { Alert, Button } from '../components/ui' +import { paths } from '../routes/appRoutes' +import { api } from '../services/api' +import { isEmail } from '../utils/validators' + +export default function ForgotPassword() { + const [params, setParams] = useSearchParams() + const navigate = useNavigate() + const [email, setEmail] = useState(params.get('email') || '') + const [code, setCode] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const resetting = params.get('step') === 'reset' + + const requestCode = async (event) => { + event.preventDefault() + setError('') + if (!isEmail(email)) { setError('Enter a valid email address.'); return } + setBusy(true) + try { + const result = await api.auth.forgotPassword(email) + setNotice(result.message) + setParams({ email, step: 'reset' }, { replace: true }) + } catch (cause) { + setError(cause.message || 'A reset code could not be requested.') + } finally { + setBusy(false) + } + } + + const reset = async (event) => { + event.preventDefault() + setError('') + if (code.length !== 6) { setError('Enter the six-digit code from your email.'); return } + if (password.length < 8) { setError('Use at least 8 characters for your password.'); return } + if (password !== confirmPassword) { setError('Passwords do not match.'); return } + setBusy(true) + try { + await api.auth.resetPassword({ email, code, password, confirmPassword }) + navigate(paths.login, { replace: true, state: { notice: 'Password reset. Log in with your new password.' } }) + } catch (cause) { + setError(cause.message || 'Your password could not be reset.') + setBusy(false) + } + } + + const resend = async () => { + setError('') + setBusy(true) + try { + const result = await api.auth.forgotPassword(email) + setNotice(result.message) + } catch (cause) { + setError(cause.message || 'A reset code could not be requested.') + } finally { + setBusy(false) + } + } + + return Remembered it? Log in} + > + {!resetting + ?
+ setEmail(event.target.value)} autoComplete="email" /> + {error && {error}} + + + :
+ setEmail(event.target.value)} autoComplete="email" /> + setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} /> + setPassword(event.target.value)} hint="Use at least 8 characters." /> + setConfirmPassword(event.target.value)} /> + {notice && {notice}} + {error && {error}} + + + } +
+} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 9241bb6..fc6b816 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import AuthShell from '../components/AuthShell' import Field from '../components/Field' import { Alert, Arrow, Button } from '../components/ui' @@ -9,6 +9,7 @@ import { validateLogin } from '../utils/validators' export default function Login() { const { login } = useAuth() + const location = useLocation() const [values, setValues] = useState({ email: '', password: '' }) const [errors, setErrors] = useState({}) const [formError, setFormError] = useState('') @@ -31,6 +32,8 @@ export default function Login() {
+

Forgot password? · Verify email

+ {location.state?.notice && {location.state.notice}} {formError && {formError}} diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index df23f44..5fece6b 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -1,19 +1,17 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import AuthShell from '../components/AuthShell' import Field from '../components/Field' import { Alert, Arrow, Button } from '../components/ui' import { useAuth } from '../context/AuthContext' -import { useNotifications } from '../context/NotificationContext' import { paths } from '../routes/appRoutes' -import { api } from '../services/api' import { validateSignup } from '../utils/validators' const EMPTY = { firstName: '', surname: '', email: '', password: '', confirmPassword: '' } export default function Signup() { const { signup } = useAuth() - const { notify } = useNotifications() + const navigate = useNavigate() const [values, setValues] = useState(EMPTY) const [errors, setErrors] = useState({}) const [formError, setFormError] = useState('') @@ -28,13 +26,12 @@ export default function Signup() { setBusy(true) setFormError('') try { - await signup(values) // GuestOnly redirects once the session exists - const session = await api.auth.session() // fresh read: safe even before NotificationProvider re-renders with the new user - if (session) notify({ type: 'welcome', title: 'Welcome to Signpak Commons', body: 'Pick any category and record your first video whenever you\'re ready.' }, session.id) + const result = await signup(values) + navigate(`${paths.verifyEmail}?email=${encodeURIComponent(result.email)}`, { replace: true, state: { emailSent: result.verificationEmailSent } }) } catch (error) { setFormError(error.message); setBusy(false) } } - return Already have an account? Log in}> + return Already signed up? Verify your email · Log in}>
diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx new file mode 100644 index 0000000..86286a7 --- /dev/null +++ b/frontend/src/pages/VerifyEmail.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import AuthShell from '../components/AuthShell' +import Field from '../components/Field' +import { Alert, Button } from '../components/ui' +import { useAuth } from '../context/AuthContext' +import { paths } from '../routes/appRoutes' +import { api } from '../services/api' + +export default function VerifyEmail() { + const [params] = useSearchParams() + const location = useLocation() + const navigate = useNavigate() + const { verifyEmail } = useAuth() + const [email, setEmail] = useState(params.get('email') || '') + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState(location.state?.emailSent === false + ? 'We could not send the code yet. Try sending a new code.' + : params.get('email') ? 'Enter the six-digit code sent to your email.' : '') + + const submit = async (event) => { + event.preventDefault() + setError('') + setBusy(true) + try { + await verifyEmail(email, code) + navigate(paths.library, { replace: true }) + } catch (cause) { + setError(cause.message || 'We could not verify that code.') + setBusy(false) + } + } + + const resend = async () => { + setError('') + setBusy(true) + try { + const response = await api.auth.resendVerification(email) + setNotice(response.message) + } catch (cause) { + setError(cause.message || 'A new code could not be sent.') + } finally { + setBusy(false) + } + } + + return Already verified? Log in} + > + + setEmail(event.target.value)} autoComplete="email" /> + setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} /> + {notice && {notice}} + {error && {error}} + + + + +} diff --git a/frontend/src/routes/appRoutes.js b/frontend/src/routes/appRoutes.js index 1f02747..5bf4dc5 100644 --- a/frontend/src/routes/appRoutes.js +++ b/frontend/src/routes/appRoutes.js @@ -17,6 +17,8 @@ export const paths = { policy: '/#policy', login: '/login', signup: '/signup', + verifyEmail: '/verify-email', + forgotPassword: '/forgot-password', library: '/home', profile: '/profile', watch: '/watch', diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 90fc2e6..54dfccd 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -87,6 +87,10 @@ export const api = { async session() { return normalize(await request(apiRoutes.auth.session())) }, async register(values) { return normalize(await request(apiRoutes.auth.signup(), json('POST', values))) }, async login(email, password) { return normalize(await request(apiRoutes.auth.login(), json('POST', { email, password }))) }, + async verifyEmail(email, code) { return normalize(await request(apiRoutes.auth.verifyEmail(), json('POST', { email, code }))) }, + async resendVerification(email) { return request(apiRoutes.auth.resendVerification(), json('POST', { email })) }, + async forgotPassword(email) { return request(apiRoutes.auth.forgotPassword(), json('POST', { email })) }, + async resetPassword(values) { return request(apiRoutes.auth.resetPassword(), json('POST', values)) }, async logout() { await request(apiRoutes.auth.logout(), { method: 'POST' }) }, async saveConnections(_userId, connections) { return normalize(await request(apiRoutes.users.me(), json('PATCH', { connections }))) }, }, diff --git a/frontend/src/services/apiRoutes.js b/frontend/src/services/apiRoutes.js index eee4915..5ebcaba 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -15,6 +15,10 @@ export const apiRoutes = { login: () => `${API_BASE}/auth/login`, logout: () => `${API_BASE}/auth/logout`, session: () => `${API_BASE}/auth/session`, + verifyEmail: () => `${API_BASE}/auth/verify-email`, + resendVerification: () => `${API_BASE}/auth/resend-verification`, + forgotPassword: () => `${API_BASE}/auth/forgot-password`, + resetPassword: () => `${API_BASE}/auth/reset-password`, }, users: { From c2c6336b6f7dd12cdaccacba388ee1c830b64469 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 12:44:39 +0500 Subject: [PATCH 5/6] feat: web3forms --- backend/.env.example | 6 +++ backend/README.md | 6 +-- backend/docs/API.md | 4 +- backend/src/config/env.js | 7 +++ backend/src/controllers/contactController.js | 9 +++- backend/src/docs/openapi.js | 2 +- backend/src/models/AccessRestriction.js | 4 +- backend/src/models/ContactDailyQuota.js | 8 +++ backend/src/models/ContactMessage.js | 8 ++- backend/src/models/ContactUserQuota.js | 10 ++++ backend/src/models/User.js | 2 +- backend/src/repositories/contactQuotaRepo.js | 42 +++++++++++++++ backend/src/repositories/messageRepo.js | 1 + backend/src/routes/contactRoutes.js | 3 +- .../src/services/accessRestrictionService.js | 32 +++++++++++- backend/src/services/authService.js | 2 +- backend/src/services/contactService.js | 52 ++++++++++++++++++- .../services/external/sendContactWeb3Forms.js | 24 +++++++++ backend/tests/api.test.js | 42 ++++++++++++--- frontend/README.md | 1 + frontend/src/pages/Landing.jsx | 24 ++++++--- 21 files changed, 261 insertions(+), 28 deletions(-) create mode 100644 backend/src/models/ContactDailyQuota.js create mode 100644 backend/src/models/ContactUserQuota.js create mode 100644 backend/src/repositories/contactQuotaRepo.js create mode 100644 backend/src/services/external/sendContactWeb3Forms.js diff --git a/backend/.env.example b/backend/.env.example index 8718372..13f25c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,12 @@ AUTH_OTP_TTL_MINUTES=5 AUTH_OTP_RESEND_SECONDS=60 AUTH_OTP_MAX_ATTEMPTS=5 +# Web3Forms contact form delivery; get the access key from web3forms.com. +WEB3FORMS_ACCESS_KEY= +CONTACT_DAILY_LIMIT=25 +CONTACT_USER_DAILY_LIMIT=1 +CONTACT_ABUSE_BLOCK_MINUTES=10 + # The admin account is created (or promoted) from these on every start. ADMIN_EMAIL=admin@signpak.dev ADMIN_PASSWORD= diff --git a/backend/README.md b/backend/README.md index 0ff7c6b..feadff9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,7 @@ Express + Node.js + MongoDB (Mongoose). Cookie-based auth, local base-video stor ```bash cd backend npm install -cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD, MONGODB_URI and Brevo sender credentials +cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD, MONGODB_URI and service credentials npm run dev # http://localhost:5000, health check at /api/v1/health npm test # integration tests (see "Testing") ``` @@ -72,7 +72,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, | `GET /submissions` | logged in | Contributors: their own. Admin: all | | `POST /submissions` | logged in | `multipart/form-data`: `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, `duration` | | `GET /submissions/:id/recording` | none | Deliberately unavailable; submissions are append-only archives | -| `POST /contact` | anyone | Rate limited: 5 per hour per IP | +| `POST /contact` | verified user | Account email must match; one per user/day and 25 total/day; delivered via Web3Forms | | `GET /admin/users`, `/admin/stats?days=14`, `/admin/messages` | admin | | ## Decisions worth knowing @@ -95,7 +95,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, ## Testing -`npm test` runs the HTTP integration suite for auth, roles, uploads, Range streaming, the cooldown rule, stats, contact, and cleanup of temporary files. Authentication email delivery is stubbed in tests; the application mail path uses Brevo. +`npm test` runs the HTTP integration suite for auth, roles, uploads, Range streaming, the cooldown rule, stats, contact quotas, and cleanup of temporary files. Brevo and Web3Forms delivery are stubbed in tests; configure their environment keys to enable real email delivery. - Set `MONGODB_URI_TEST` to run against a MongoDB you already have (it uses that database and **drops it**, so point it at a throwaway one). - Otherwise it starts `mongodb-memory-server`, which downloads a MongoDB binary on first use. diff --git a/backend/docs/API.md b/backend/docs/API.md index 3e87fc7..7cc7398 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -64,7 +64,7 @@ Errors use a structured object suitable for form-level and field-level messages: | POST/DELETE | `/demo-video` | Admin | Upload/replace or delete the walkthrough video. | | GET/POST | `/submissions` | User | List or create contributor recordings. | | GET | `/submissions/:id/recording` | None | Deliberately unavailable; submission archives are append-only. | -| POST | `/contact` | Public | Send a contact message. | +| POST | `/contact` | Verified user | Send a message from the account email; one per account per UTC day. | | GET | `/admin/users` | Admin | List users. | | GET | `/admin/stats` | Admin | Read dashboard statistics. | | GET | `/admin/messages` | Admin | Read contact messages. | @@ -77,6 +77,8 @@ The public demo video is a separate singleton asset, independent of the lesson l The server validates file signatures, size limits, metadata, and route IDs. Client-reported durations are currently accepted for trimming metadata; server-side media probing and trimming are future work. +Contact messages are delivered to the recipient configured by the `WEB3FORMS_ACCESS_KEY` in Web3Forms and are also retained in the admin inbox. Only authenticated users can send, the submitted email must match their account, each account can send one message per UTC day, and total successful deliveries are capped at 25 per UTC day. Anonymous or email-mismatched attempts, and attempts after the global cap, temporarily restrict that device (or IP if no device ID is supplied) for 10 minutes. + ## Visibility rules - Anonymous users see only published videos assigned to a category. diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 464efe0..c257d61 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -25,6 +25,10 @@ const schema = z.object({ AUTH_OTP_TTL_MINUTES: z.coerce.number().int().min(1).max(30).default(5), AUTH_OTP_RESEND_SECONDS: z.coerce.number().int().min(0).max(3600).default(60), AUTH_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5), + WEB3FORMS_ACCESS_KEY: z.string().optional(), + CONTACT_DAILY_LIMIT: z.coerce.number().int().min(1).default(25), + CONTACT_USER_DAILY_LIMIT: z.coerce.number().int().min(1).default(1), + CONTACT_ABUSE_BLOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(10), ADMIN_EMAIL: z.email({ error: 'ADMIN_EMAIL is required and must be a valid email' }), ADMIN_PASSWORD: z.string({ error: 'ADMIN_PASSWORD is required (at least 8 characters)' }).min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72), @@ -52,6 +56,9 @@ const schema = z.object({ if (data.NODE_ENV === 'production' && !data.BREVO_SENDER_EMAIL) { context.addIssue({ code: 'custom', path: ['BREVO_SENDER_EMAIL'], message: 'Required in production; use a verified Brevo sender address.' }) } + if (data.NODE_ENV === 'production' && !data.WEB3FORMS_ACCESS_KEY) { + context.addIssue({ code: 'custom', path: ['WEB3FORMS_ACCESS_KEY'], message: 'Required in production to deliver contact messages.' }) + } const needsGoogle = data.STORAGE_DRIVER === 'gdrive' || data.ARCHIVE_STORAGE_DRIVER === 'gdrive' if (needsGoogle && !data.GOOGLE_SERVICE_ACCOUNT_JSON) { context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' }) diff --git a/backend/src/controllers/contactController.js b/backend/src/controllers/contactController.js index 9b98167..40d7f16 100644 --- a/backend/src/controllers/contactController.js +++ b/backend/src/controllers/contactController.js @@ -3,6 +3,13 @@ import { asyncHandler } from '../utils/asyncHandler.js' import { created, ok } from '../utils/response.js' export const contactController = { - send: asyncHandler(async (req, res) => { await contactService.send(req.body); created(res, { received: true }) }), + send: asyncHandler(async (req, res) => { + await contactService.send(req.body, { + user: req.user, + ip: req.ip, + deviceId: req.get('X-Device-ID')?.slice(0, 128), + }) + created(res, { received: true }) + }), list: asyncHandler(async (req, res) => ok(res, await contactService.list(req.query.limit))), } diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 4ce3846..7235a1e 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -153,7 +153,7 @@ export const openapi = { get: { tags: ['Submissions'], summary: 'List the current user submissions', security: userSecurity, responses: { 200: { description: 'Submissions' }, 401: errorResponse } }, post: { tags: ['Submissions'], summary: 'Submit a recording', security: userSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['recording', 'videoId'], properties: { recording: { type: 'string', format: 'binary' }, videoId: { type: 'string' }, trimStart: { type: 'number' }, trimEnd: { type: 'number' }, mirrored: { type: 'boolean' }, duration: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, }, - '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent' }, 400: errorResponse } } }, + '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message from the authenticated account email', security: userSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent to Web3Forms and saved to the admin inbox' }, 403: errorResponse, 409: errorResponse, 429: errorResponse } } }, '/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } }, '/admin/users/{id}': { parameters: [idParameter], diff --git a/backend/src/models/AccessRestriction.js b/backend/src/models/AccessRestriction.js index dcab8b0..5ac2a8d 100644 --- a/backend/src/models/AccessRestriction.js +++ b/backend/src/models/AccessRestriction.js @@ -6,7 +6,8 @@ const accessRestrictionSchema = new mongoose.Schema({ identifierHash: { type: String, required: true }, identifierHint: { type: String, required: true }, reason: { type: String, trim: true, maxlength: 500, default: '' }, - createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, + expiresAt: { type: Date, default: null }, }, { timestamps: true, versionKey: false, @@ -14,5 +15,6 @@ const accessRestrictionSchema = new mongoose.Schema({ }) accessRestrictionSchema.index({ type: 1, identifierHash: 1 }, { unique: true }) +accessRestrictionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }) export const AccessRestriction = mongoose.model('AccessRestriction', accessRestrictionSchema) \ No newline at end of file diff --git a/backend/src/models/ContactDailyQuota.js b/backend/src/models/ContactDailyQuota.js new file mode 100644 index 0000000..33fad1f --- /dev/null +++ b/backend/src/models/ContactDailyQuota.js @@ -0,0 +1,8 @@ +import mongoose from 'mongoose' + +const contactDailyQuotaSchema = new mongoose.Schema({ + _id: { type: String }, + count: { type: Number, default: 0, min: 0 }, +}, { versionKey: false }) + +export const ContactDailyQuota = mongoose.model('ContactDailyQuota', contactDailyQuotaSchema) diff --git a/backend/src/models/ContactMessage.js b/backend/src/models/ContactMessage.js index 229e71e..adadbd4 100644 --- a/backend/src/models/ContactMessage.js +++ b/backend/src/models/ContactMessage.js @@ -2,10 +2,16 @@ import mongoose from 'mongoose' import { jsonOptions } from '../utils/mongoose.js' const contactMessageSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, name: { type: String, required: true, trim: true, maxlength: 100 }, email: { type: String, required: true, lowercase: true, trim: true, maxlength: 254 }, message: { type: String, required: true, trim: true, maxlength: 2000 }, -}, { timestamps: true, toJSON: jsonOptions((doc, ret) => { delete ret.updatedAt; return ret }) }) +}, { timestamps: true, toJSON: jsonOptions((doc, ret) => { + ret.userId = ret.user ? String(ret.user) : null + delete ret.user + delete ret.updatedAt + return ret +}) }) contactMessageSchema.index({ createdAt: -1 }) diff --git a/backend/src/models/ContactUserQuota.js b/backend/src/models/ContactUserQuota.js new file mode 100644 index 0000000..dfd21d1 --- /dev/null +++ b/backend/src/models/ContactUserQuota.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose' + +const contactUserQuotaSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, required: true }, + day: { type: String, required: true }, +}, { versionKey: false }) + +contactUserQuotaSchema.index({ user: 1, day: 1 }, { unique: true }) + +export const ContactUserQuota = mongoose.model('ContactUserQuota', contactUserQuotaSchema) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 50f3668..7448259 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -8,7 +8,7 @@ const userSchema = new mongoose.Schema({ surname: { type: String, required: true, trim: true, maxlength: 60 }, passwordHash: { type: String, required: true, select: false }, authVersion: { type: Number, default: 0 }, - emailVerified: { type: Boolean, default: false }, + emailVerified: { type: Boolean, default: true }, emailVerificationCodeHash: { type: String, default: null, select: false }, emailVerificationExpiresAt: { type: Date, default: null, select: false }, emailVerificationAttempts: { type: Number, default: 0, select: false }, diff --git a/backend/src/repositories/contactQuotaRepo.js b/backend/src/repositories/contactQuotaRepo.js new file mode 100644 index 0000000..ba67f2a --- /dev/null +++ b/backend/src/repositories/contactQuotaRepo.js @@ -0,0 +1,42 @@ +import { ContactDailyQuota } from '../models/ContactDailyQuota.js' +import { ContactUserQuota } from '../models/ContactUserQuota.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' + +export const contactQuotaRepo = { + async claimUser(userId, day) { + try { + await ContactUserQuota.create({ user: userId, day }) + return true + } catch (error) { + if (isDuplicateKeyError(error)) return false + throw error + } + }, + + releaseUser(userId, day) { + return ContactUserQuota.deleteOne({ user: userId, day }) + }, + + async claimDaily(day, maximum) { + try { + await ContactDailyQuota.findOneAndUpdate( + { _id: day, count: { $lt: maximum } }, + { $inc: { count: 1 } }, + { upsert: true, new: true }, + ) + return true + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const updated = await ContactDailyQuota.findOneAndUpdate( + { _id: day, count: { $lt: maximum } }, + { $inc: { count: 1 } }, + { new: true }, + ) + return Boolean(updated) + } + }, + + releaseDaily(day) { + return ContactDailyQuota.updateOne({ _id: day, count: { $gt: 0 } }, { $inc: { count: -1 } }) + }, +} diff --git a/backend/src/repositories/messageRepo.js b/backend/src/repositories/messageRepo.js index ea3e764..7cbebfb 100644 --- a/backend/src/repositories/messageRepo.js +++ b/backend/src/repositories/messageRepo.js @@ -2,5 +2,6 @@ import { ContactMessage } from '../models/ContactMessage.js' export const messageRepo = { create: (data) => ContactMessage.create(data), + remove: (id) => ContactMessage.deleteOne({ _id: id }), list: (limit) => ContactMessage.find().sort({ createdAt: -1 }).limit(limit), } diff --git a/backend/src/routes/contactRoutes.js b/backend/src/routes/contactRoutes.js index 3865d10..a531cc5 100644 --- a/backend/src/routes/contactRoutes.js +++ b/backend/src/routes/contactRoutes.js @@ -1,4 +1,5 @@ import { Router } from 'express' +import { optionalAuthenticate } from '../middlewares/authenticate.js' import { contactController } from '../controllers/contactController.js' import { contactLimiter } from '../middlewares/rateLimiter.js' import { validate } from '../middlewares/validate.js' @@ -6,6 +7,6 @@ import { contactSchema } from '../middlewares/validators/miscSchemas.js' const router = Router() -router.post('/', contactLimiter, validate({ body: contactSchema }), contactController.send) +router.post('/', contactLimiter, optionalAuthenticate, validate({ body: contactSchema }), contactController.send) export default router diff --git a/backend/src/services/accessRestrictionService.js b/backend/src/services/accessRestrictionService.js index d83efa9..736c65a 100644 --- a/backend/src/services/accessRestrictionService.js +++ b/backend/src/services/accessRestrictionService.js @@ -14,7 +14,7 @@ function hint(type, value) { } export const accessRestrictionService = { - list: () => AccessRestriction.find().sort({ createdAt: -1 }), + list: () => AccessRestriction.find({ $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] }).sort({ createdAt: -1 }), async create({ type, value, reason }, adminId) { try { @@ -39,6 +39,34 @@ export const accessRestrictionService = { async isRequestRestricted(ip, deviceId) { const identifiers = [{ type: 'ip', identifierHash: hash(ip) }] if (deviceId) identifiers.push({ type: 'device', identifierHash: hash(deviceId) }) - return AccessRestriction.exists({ $or: identifiers }) + return AccessRestriction.exists({ $and: [ + { $or: identifiers }, + { $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] }, + ] }) + }, + + async temporarilyRestrictRequest(ip, deviceId, reason, durationMs) { + const target = deviceId ? { type: 'device', value: deviceId } : { type: 'ip', value: ip } + const filter = { type: target.type, identifierHash: hash(target.value) } + const expiresAt = new Date(Date.now() + durationMs) + let restriction = await AccessRestriction.findOne(filter) + if (restriction) { + if (!restriction.expiresAt) return + restriction.expiresAt = expiresAt + restriction.reason = reason + await restriction.save() + return + } + try { + await AccessRestriction.create({ ...filter, identifierHint: hint(target.type, target.value), reason, expiresAt }) + } catch (error) { + if (error?.code !== 11000) throw error + restriction = await AccessRestriction.findOne(filter) + if (restriction?.expiresAt) { + restriction.expiresAt = expiresAt + restriction.reason = reason + await restriction.save() + } + } }, } \ No newline at end of file diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index a4f8e70..5119dfc 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -33,7 +33,7 @@ export const authService = { if (await userRepo.findByEmail(email)) throw emailTaken() const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) try { - const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER }) + const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER, emailVerified: false }) const verificationEmailSent = await this.issueOtp(user, 'email-verification') return { email: user.email, verificationEmailSent } } catch (error) { diff --git a/backend/src/services/contactService.js b/backend/src/services/contactService.js index 77b2a97..61ddf29 100644 --- a/backend/src/services/contactService.js +++ b/backend/src/services/contactService.js @@ -1,6 +1,56 @@ +import { env } from '../config/env.js' +import { contactQuotaRepo } from '../repositories/contactQuotaRepo.js' import { messageRepo } from '../repositories/messageRepo.js' +import { accessRestrictionService } from './accessRestrictionService.js' +import { sendContactWeb3Forms } from './external/sendContactWeb3Forms.js' +import { conflict, forbidden, tooManyRequests } from '../utils/AppError.js' + +const utcDay = () => new Date().toISOString().slice(0, 10) +const dailyLimitError = () => tooManyRequests('The contact form has reached its daily message limit. Try again tomorrow.') export const contactService = { - send: ({ name, email, message }) => messageRepo.create({ name, email, message }), + deliver: sendContactWeb3Forms, + + async send({ name, email, message }, { user, ip, deviceId }) { + const normalizedEmail = email.trim().toLowerCase() + if (!user || normalizedEmail !== user.email) { + await accessRestrictionService.temporarilyRestrictRequest( + ip, + deviceId, + user ? 'Contact form email does not match the signed-in account.' : 'Anonymous contact form submission.', + env.CONTACT_ABUSE_BLOCK_MINUTES * 60_000, + ) + throw forbidden(user ? 'Use the email address on your account to contact us.' : 'Log in with a verified account to contact us.') + } + + const day = utcDay() + const userClaimed = await contactQuotaRepo.claimUser(user._id, day) + if (!userClaimed) throw conflict('You can send one contact message per day.') + + let dailyClaimed = false + let savedMessage + try { + dailyClaimed = await contactQuotaRepo.claimDaily(day, env.CONTACT_DAILY_LIMIT) + if (!dailyClaimed) { + await accessRestrictionService.temporarilyRestrictRequest( + ip, + deviceId, + 'Daily contact message limit exceeded.', + env.CONTACT_ABUSE_BLOCK_MINUTES * 60_000, + ) + throw dailyLimitError() + } + + savedMessage = await messageRepo.create({ user: user._id, name, email: normalizedEmail, message }) + await this.deliver({ name, email: normalizedEmail, message }) + return savedMessage + } catch (error) { + if (savedMessage) await messageRepo.remove(savedMessage._id).catch(() => {}) + if (dailyClaimed) await contactQuotaRepo.releaseDaily(day) + await contactQuotaRepo.releaseUser(user._id, day) + throw error + } + }, + list: (limit) => messageRepo.list(limit), } diff --git a/backend/src/services/external/sendContactWeb3Forms.js b/backend/src/services/external/sendContactWeb3Forms.js new file mode 100644 index 0000000..de5e72e --- /dev/null +++ b/backend/src/services/external/sendContactWeb3Forms.js @@ -0,0 +1,24 @@ +import { env } from '../../config/env.js' +import { AppError } from '../../utils/AppError.js' + +export async function sendContactWeb3Forms({ name, email, message }) { + if (!env.WEB3FORMS_ACCESS_KEY) throw new Error('WEB3FORMS_ACCESS_KEY is not configured.') + + const response = await fetch('https://api.web3forms.com/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + access_key: env.WEB3FORMS_ACCESS_KEY, + name, + email, + message, + subject: `SignPak Commons contact from ${name}`, + from_name: 'SignPak Commons Contact Form', + replyto: email, + }), + }) + const result = await response.json().catch(() => null) + if (!response.ok || !result?.success) { + throw new AppError(502, 'CONTACT_DELIVERY_FAILED', 'We could not deliver your message right now. Please try again later.') + } +} diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index 6252bf0..d0e9bbf 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -5,7 +5,9 @@ import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './he const { default: app } = await import('../src/app.js') const { connectDb, disconnectDb } = await import('../src/config/db.js') const { authService } = await import('../src/services/authService.js') +const { contactService } = await import('../src/services/contactService.js') const deliveredCodes = new Map() +const { ContactDailyQuota } = await import('../src/models/ContactDailyQuota.js') const { storageService } = await import('../src/services/storage/index.js') const { submissionRepo } = await import('../src/repositories/submissionRepo.js') const { Submission } = await import('../src/models/Submission.js') @@ -14,11 +16,16 @@ const { default: mongoose } = await import('mongoose') let server, memory, base, admin, contributor, other let eveSession +const contactDeliveries = [] const asJson = (res) => res.body const contributorData = { firstName: 'Sara', surname: 'Ahmed', email: 'Sara@Example.com', password: 'password1', confirmPassword: 'password1' } before(async () => { authService.sendOTPEmail = async (email, code, purpose) => deliveredCodes.set(`${email.toLowerCase()}:${purpose}`, code) + contactService.deliver = async (payload) => { + if (payload.message === 'Provider failure') throw new Error('provider unavailable') + contactDeliveries.push(payload) + } let uri = process.env.MONGODB_URI_TEST if (!uri) { const { MongoMemoryServer } = await import('mongodb-memory-server') @@ -83,10 +90,10 @@ describe('basics', () => { test('cross-origin writes are refused, allowed origin passes', async () => { const c = new Client(base) - const evil = await c.post('/contact', { name: 'a', email: 'a@b.co', message: 'hi' }, { headers: { origin: 'https://evil.example' } }) + const evil = await c.request('POST', '/auth/logout', { headers: { origin: 'https://evil.example' } }) assert.equal(evil.status, 403) - const fine = await c.post('/contact', { name: 'a', email: 'a@b.co', message: 'hi' }, { headers: { origin: 'http://localhost:5173' } }) - assert.equal(fine.status, 201) + const fine = await c.request('POST', '/auth/logout', { headers: { origin: 'http://localhost:5173' } }) + assert.equal(fine.status, 204) }) }) @@ -473,15 +480,34 @@ describe('admin stats and contact', () => { assert.equal((await contributor.get(`/videos/${video1.id}`)).status, 404) // hidden from contributors now }) - test('contact messages: public to send, admin to read', async () => { - const c = new Client(base) - const bad = await c.post('/contact', { name: '', email: 'nope', message: '' }) + test('contact delivery requires matching account email, enforces quotas, and blocks abusive devices', async () => { + const bad = await contributor.post('/contact', { name: '', email: 'nope', message: '' }) assert.equal(bad.status, 422) assert.ok(bad.body.error.fields.email) - assert.equal((await c.post('/contact', { name: 'Maya', email: 'maya@example.com', message: 'Hello!' })).status, 201) + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Provider failure' })).status, 500) + const sent = await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Hello!' }) + assert.equal(sent.status, 201) + assert.equal(contactDeliveries.length, 1) + assert.equal(contactDeliveries[0].email, 'sara@example.com') + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Again today' })).status, 409) + + const mismatchDevice = 'device-contact-email-mismatch' + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'other@example.com', message: 'Wrong email' }, { headers: { 'x-device-id': mismatchDevice } })).status, 403) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': mismatchDevice } })).status, 403) + + const today = new Date().toISOString().slice(0, 10) + await ContactDailyQuota.findByIdAndUpdate(today, { count: 25 }, { upsert: true }) + const cappedDevice = 'device-contact-global-cap' + assert.equal((await other.post('/contact', { name: 'Omar Khan', email: 'omar@example.com', message: 'Over cap' }, { headers: { 'x-device-id': cappedDevice } })).status, 429) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': cappedDevice } })).status, 403) + + const anonymousDevice = 'device-contact-anonymous-test' + assert.equal((await new Client(base).post('/contact', { name: 'Guest', email: 'guest@example.com', message: 'No session' }, { headers: { 'x-device-id': anonymousDevice } })).status, 403) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': anonymousDevice } })).status, 403) + const list = await admin.get('/admin/messages') assert.equal(list.status, 200) - assert.ok(list.body.data.some((m) => m.name === 'Maya' && m.message === 'Hello!')) + assert.ok(list.body.data.some((m) => m.name === 'Sara Ahmed' && m.message === 'Hello!' && m.userId)) assert.equal((await contributor.get('/admin/messages')).status, 403) }) diff --git a/frontend/README.md b/frontend/README.md index fb5da6f..47d05d8 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -56,6 +56,7 @@ Every server call goes through `src/services/api.js`. Requests use the `/api/v1` - Recording submissions upload the recorded `Blob` with trim metadata. The backend enforces the submission cooldown. - Notifications remain browser-local until a persisted backend notification API is added. - Signup requires a Brevo-delivered email code before the API establishes a session; password recovery uses a separate one-time code. +- Contact messages require a verified signed-in account and must use its email address; the backend limits accounts to one message per UTC day and all messages to 25 per UTC day. ## Theming diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index f59d32b..8b24411 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -189,7 +189,7 @@ export default function Landing() { contact@pslai.org */}
- +
{/* FOOTER */} @@ -199,22 +199,34 @@ export default function Landing() { } function ContactForm() { - const [values, setValues] = useState({ name: '', email: '', message: '' }) + const { user } = useAuth() + const [values, setValues] = useState(() => ({ name: user ? `${user.firstName} ${user.surname}` : '', email: user?.email || '', message: '' })) const [status, setStatus] = useState('idle') // idle | sending | sent | error + const [errorMessage, setErrorMessage] = useState('') const change = (event) => setValues((current) => ({ ...current, [event.target.name]: event.target.value })) const submit = async (event) => { event.preventDefault() setStatus('sending') + setErrorMessage('') try { await api.contact.send(values) - setValues({ name: '', email: '', message: '' }) + setValues({ name: `${user.firstName} ${user.surname}`, email: user.email, message: '' }) setStatus('sent') - } catch { + } catch (error) { + setErrorMessage(error.message || 'Your message could not be sent. Try again.') setStatus('error') } } + if (!user) { + return
+

Sign in to contact us.

+

Contact messages are limited to one per account each day. Sign in with a verified email address to send yours.

+ Log in to continue +
+ } + if (status === 'sent') { return (
@@ -244,7 +256,7 @@ function ContactForm() { name="email" type="email" value={values.email} - onChange={change} + readOnly placeholder="you@example.com" required autoComplete="email" @@ -259,7 +271,7 @@ function ContactForm() { rows={5} required /> - {status === 'error' && Your message could not be sent. Try again.} + {status === 'error' && {errorMessage}} From 02f057fa4e0fa59f6b9a71a9c86246754249bf4c Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 13:00:49 +0500 Subject: [PATCH 6/6] fix --- frontend/src/styles/admin.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 5b0949e..1e60927 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -73,6 +73,7 @@ .upload-file b { @apply text-heading; } .upload-file label { @apply ml-2; } .upload-fields > .field:first-child { @apply mt-0; } + .upload-fields > .btn { @apply mt-4; } .video-list { @apply m-0 list-none p-0; } .video-row { @apply border-t border-line py-5; }