From fe299459dbf822eecff156641399e5e607113904 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Wed, 14 Oct 2026 17:25:14 +0500 Subject: [PATCH 1/4] fr: Consent notice (basic flow step 7, OR-19) --- backend/README.md | 6 +- backend/docs/API.md | 16 +++- backend/docs/TEST-CASES.md | 22 +++-- backend/src/config/constants.js | 2 + backend/src/controllers/authController.js | 12 ++- backend/src/controllers/userController.js | 1 + backend/src/docs/openapi.js | 37 +++++++- .../src/middlewares/validators/authSchemas.js | 10 +++ .../src/middlewares/validators/userSchemas.js | 5 ++ backend/src/models/User.js | 5 ++ backend/src/routes/authRoutes.js | 4 +- backend/src/routes/userRoutes.js | 3 +- backend/src/services/authService.js | 30 ++++++- backend/src/services/submissionService.js | 7 +- backend/src/services/userService.js | 10 ++- backend/src/utils/tokens.js | 13 +++ backend/tests/api-auth.test.js | 17 ++-- backend/tests/api-consent.test.js | 87 +++++++++++++++++++ backend/tests/apiContext.js | 15 +++- frontend/README.md | 13 ++- frontend/docs/architecture.md | 19 ++-- frontend/src/App.jsx | 3 + frontend/src/components/RouteGuards.jsx | 3 + frontend/src/context/AuthProvider.jsx | 14 ++- frontend/src/pages/Consent.jsx | 77 ++++++++++++++++ frontend/src/pages/VerifyEmail.jsx | 4 +- frontend/src/routes/appRoutes.js | 2 + frontend/src/services/api.js | 6 +- frontend/src/services/apiRoutes.js | 3 + frontend/src/styles/auth.css | 5 ++ frontend/src/utils/consent.js | 13 +++ 31 files changed, 420 insertions(+), 44 deletions(-) create mode 100644 backend/tests/api-consent.test.js create mode 100644 frontend/src/pages/Consent.jsx create mode 100644 frontend/src/utils/consent.js diff --git a/backend/README.md b/backend/README.md index 6195ebd..6a7c12f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -92,7 +92,9 @@ mounted outside production. ## Operational rules - Authentication uses an HTTP-only `signpak_token` cookie. Email verification is - required before login; password reset invalidates existing sessions. + followed by consent acceptance before a new account is activated and signed + in. Existing contributors must accept the current consent version before + submitting; password reset invalidates existing sessions. - `CLIENT_ORIGIN` controls credentialed CORS and the check for browser state-changing requests that carry an `Origin` header. - The global, auth, contact, and upload rate limits can be disabled with @@ -124,7 +126,7 @@ npm test The command runs Node's built-in test runner serially across every `tests/*.test.js` file. The API suites use MongoDB Memory Server unless `MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests, -so use only a disposable database. Provider delivery is stubbed. The 57 cases and +so use only a disposable database. Provider delivery is stubbed. The 60 cases and their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md). ## Current limitation diff --git a/backend/docs/API.md b/backend/docs/API.md index 1cf6cf6..c5f6cea 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -8,9 +8,14 @@ and `/api-docs.json` in all environments. Swagger UI is disabled in production. ## Authentication and cookies -Successful login and email verification set an HTTP-only `signpak_token` cookie. -Signup creates an unverified account and sends a six-digit verification code; the -account must be verified before login. Browser clients must send requests with +Successful login and consent acceptance set an HTTP-only `signpak_token` cookie. +Signup creates a pending, unverified record and sends a six-digit verification +code. A valid code returns a short-lived consent token but does not establish a +session or activate the account. `POST /auth/consent` accepts the current notice, +activates the account, stores the consent version and timestamp, and starts the +session. `POST /auth/decline-consent` discards the pending signup. Existing +contributors without current consent are redirected to the notice and cannot +submit recordings until they accept. Browser clients must send requests with credentials. `GET /auth/session` returns the current user or `null` for a visitor. `POST @@ -57,7 +62,9 @@ All paths below are relative to `/api/v1`. | GET | `/health` | Public | Report API uptime and database connectivity; returns `503` while the database is unavailable. | | POST | `/auth/signup` | Public | Create an unverified contributor and send an email code. | | POST | `/auth/login` | Public | Authenticate and set the session cookie. | -| POST | `/auth/verify-email` | Public | Verify an email code and establish a session. | +| POST | `/auth/verify-email` | Public | Verify an email code and return a short-lived consent token. | +| POST | `/auth/consent` | Public | Accept the current consent notice, activate a pending signup, and start a session. | +| POST | `/auth/decline-consent` | Public | Discard a pending signup after consent is declined. | | POST | `/auth/resend-verification` | Public | Request another verification code. | | POST | `/auth/forgot-password` | Public | Request a password-reset code. | | POST | `/auth/reset-password` | Public | Reset a password and invalidate older sessions. | @@ -65,6 +72,7 @@ All paths below are relative to `/api/v1`. | GET | `/auth/session` | Public | Return the current user or `null`. | | GET | `/users/me` | Authenticated user | Read the signed-in user's profile. | | PATCH | `/users/me` | Authenticated user | Update supported profile connections. | +| POST | `/users/me/consent` | Authenticated user | Accept the current notice for an existing account. | | GET | `/categories` | Public | List categories. | | POST | `/categories` | Admin | Create a category. | | PATCH | `/categories/:id` | Admin | Update a category. | diff --git a/backend/docs/TEST-CASES.md b/backend/docs/TEST-CASES.md index 5e7d6a9..c182007 100644 --- a/backend/docs/TEST-CASES.md +++ b/backend/docs/TEST-CASES.md @@ -1,7 +1,7 @@ # Backend test cases -The backend suite uses Node's built-in test runner. It currently contains **57 test -cases** across twelve test files. Each named API test suite has its own `.test.js` +The backend suite uses Node's built-in test runner. It currently contains **60 test +cases** across thirteen test files. Each named API test suite has its own `.test.js` file and starts with a fresh test database and account fixtures: | Test file | Cases | Main test type | @@ -9,6 +9,7 @@ file and starts with a fresh test database and account fixtures: | `tests/api-basics.test.js` | 5 | HTTP integration tests | | `tests/api-demo-video.test.js` | 1 | HTTP integration tests | | `tests/api-auth.test.js` | 8 | HTTP integration tests | +| `tests/api-consent.test.js` | 3 | HTTP integration tests | | `tests/api-profile-connections.test.js` | 1 | HTTP integration tests | | `tests/api-categories.test.js` | 2 | HTTP integration tests | | `tests/api-videos.test.js` | 6 | HTTP integration tests | @@ -62,10 +63,10 @@ their respective test file. Each API case below is an HTTP integration test. 7. **signup validates every field** — Checks required signup fields, password confirmation, and minimum password length. -8. **signup sets an httpOnly cookie and never leaks the hash or accepts a role** — - Checks that signup does not create a session before verification, ignores a - submitted admin role, verifies email, sets secure cookie attributes, and does - not expose the password hash. +8. **signup only creates an account after consent and never leaks the hash or + accepts a role** — Checks that signup and email verification do not create a + session, ignores a submitted admin role, activates the account after consent, + sets secure cookie attributes, and does not expose the password hash. 9. **password reset codes are one-time and replace the old password** — Checks the generic reset-request response, invalid-code handling, password replacement, code reuse rejection, and invalidation of the old session. @@ -228,6 +229,15 @@ routes. The cases are separated by the behavior under test. Checks cleanup of the stored video when the subsequent poster save fails. 57. **video create removes video and poster if the database write fails** — Checks cleanup of both stored files when database record creation fails. +58. **creates the account only after consent and records the acceptance time** — + Checks that verification alone cannot log in, current consent activates the + account, the acceptance time and version are stored, and consent tokens + cannot be replayed. +59. **declining consent discards the unfinished signup** — Checks the pending + account is removed and its email can be used for a new signup. +60. **legacy accounts without consent cannot submit until they accept** — Checks + that older accounts are blocked from submitting until they accept the current + notice. ## Running the suite diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js index 676eb8b..9b6428f 100644 --- a/backend/src/config/constants.js +++ b/backend/src/config/constants.js @@ -10,5 +10,7 @@ export const VIDEO_STATUS_LIST = Object.values(VIDEO_STATUS) export const POSTER_MAX_BYTES = 2 * 1024 * 1024 export const MAX_RECORDING_SECONDS = 600 +// Bump when the consent notice text changes materially; contributors are then asked to accept it again. +export const CONSENT_VERSION = '2026-10' // Client-reported durations are rounded, so allow a little slack when checking trim ranges. export const TRIM_TOLERANCE_SECONDS = 0.5 diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index d1fbcdb..4a57b3c 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -15,13 +15,23 @@ export const authController = { ok(res, user) }), + // A valid code does not log anyone in: it returns a short-lived consent token for the next step. verifyEmail: asyncHandler(async (req, res) => { - const { user, token } = await authService.verifyEmail(req.body) + ok(res, await authService.verifyEmail(req.body)) + }), + + acceptConsent: asyncHandler(async (req, res) => { + const { user, token } = await authService.completeSignup(req.body) await authService.recordDevice(user, req.get('X-Device-ID')) setAuthCookie(res, token) ok(res, user) }), + declineConsent: asyncHandler(async (req, res) => { + await authService.declineSignup(req.body) + noContent(res) + }), + resendVerification: asyncHandler(async (req, res) => { await authService.resendVerification(req.body.email) ok(res, { message: 'If the account needs verification, a code will be sent.' }) diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index d6be7dc..0a5dbb6 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -5,6 +5,7 @@ import { noContent, ok } from '../utils/response.js' export const userController = { me: (req, res) => ok(res, req.user), updateMe: asyncHandler(async (req, res) => ok(res, await userService.updateConnections(req.user, req.body.connections))), + acceptConsent: asyncHandler(async (req, res) => ok(res, await userService.acceptConsent(req.user, req.body.version))), list: asyncHandler(async (req, res) => ok(res, await userService.listAll())), updateStatus: asyncHandler(async (req, res) => ok(res, await userService.updateStatus(req.params.id, req.user, req.body))), remove: asyncHandler(async (req, res) => { await userService.remove(req.params.id, req.user); noContent(res) }), diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 0db3717..b906a0d 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -141,6 +141,11 @@ export const openapi = { role: { type: 'string', enum: ['user', 'admin'] }, status: { type: 'string', enum: ['active', 'suspended'] }, statusReason: { type: 'string' }, + consent: { + type: 'object', + description: 'When the contributor accepted the consent notice (null until they do).', + properties: { acceptedAt: { type: 'string', format: 'date-time', nullable: true }, version: { type: 'string', nullable: true } }, + }, connections: { type: 'object', properties: { @@ -347,9 +352,27 @@ export const openapi = { '/auth/verify-email': { post: { tags: ['Auth'], - summary: 'Verify an email code and establish a session', + summary: 'Verify an email code', + description: 'A valid code does not create the account or log anyone in. It returns a short-lived consentToken to use with POST /auth/consent.', requestBody: { required: true, content: { 'application/json': { schema: ref('VerifyEmailInput') } } }, - responses: { 200: jsonResponse('Verified and logged in', ref('User')), ...authErrors }, + responses: { 200: jsonResponse('Code accepted', { type: 'object', properties: { consentToken: { type: 'string' } } }), ...authErrors }, + }, + }, + '/auth/consent': { + post: { + tags: ['Auth'], + summary: 'Accept the consent notice, create the account and log in', + description: 'Activates the account, stores the acceptance date, time and notice version, and sets the session cookie.', + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['consentToken', 'accepted', 'version'], properties: { consentToken: { type: 'string' }, accepted: { type: 'boolean', enum: [true] }, version: { type: 'string' } } } } } }, + responses: { 200: jsonResponse('Account created and logged in', ref('User')), ...authErrors, 409: errorResponse }, + }, + }, + '/auth/decline-consent': { + post: { + tags: ['Auth'], + summary: 'Decline the consent notice and discard the unfinished sign-up', + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['consentToken'], properties: { consentToken: { type: 'string' } } } } } }, + responses: { 204: { description: 'Sign-up discarded' }, ...authErrors }, }, }, '/auth/resend-verification': { @@ -402,6 +425,16 @@ export const openapi = { responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 422: errorResponse }, }, }, + '/users/me/consent': { + post: { + tags: ['Users'], + summary: 'Accept the consent notice', + description: 'Records the acceptance date and time and the notice version. Submissions are refused with CONSENT_REQUIRED until this is done.', + security: userSecurity, + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['accepted', 'version'], properties: { accepted: { type: 'boolean', enum: [true] }, version: { type: 'string' } } } } } }, + responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 409: errorResponse, 422: errorResponse }, + }, + }, '/categories': { get: { tags: ['Categories'], diff --git a/backend/src/middlewares/validators/authSchemas.js b/backend/src/middlewares/validators/authSchemas.js index fd1313b..8075b4b 100644 --- a/backend/src/middlewares/validators/authSchemas.js +++ b/backend/src/middlewares/validators/authSchemas.js @@ -30,3 +30,13 @@ export const resetPasswordSchema = z.object({ 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.' }) + +export const consentSchema = z.object({ + consentToken: z.string({ error: 'This sign-up step has expired. Verify your email again.' }).min(10).max(2000), + accepted: z.literal(true, { error: 'You must accept the consent notice to create your account.' }), + version: z.string({ error: 'Missing consent version.' }).min(1).max(40), +}) + +export const declineConsentSchema = z.object({ + consentToken: z.string({ error: 'This sign-up step has expired.' }).min(10).max(2000), +}) diff --git a/backend/src/middlewares/validators/userSchemas.js b/backend/src/middlewares/validators/userSchemas.js index a76e844..9c6acd8 100644 --- a/backend/src/middlewares/validators/userSchemas.js +++ b/backend/src/middlewares/validators/userSchemas.js @@ -24,3 +24,8 @@ const linkedin = z.string().trim().nullable().transform((value, ctx) => { export const updateMeSchema = z.object({ connections: z.object({ github: github.optional(), linkedin: linkedin.optional() }), }) + +export const acceptConsentSchema = z.object({ + accepted: z.literal(true, { error: 'You must accept the consent notice to continue.' }), + version: z.string({ error: 'Missing consent version.' }).min(1).max(40), +}) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 7448259..c10e258 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -21,6 +21,11 @@ const userSchema = new mongoose.Schema({ status: { type: String, enum: ['active', 'suspended'], default: 'active' }, statusReason: { type: String, trim: true, maxlength: 500, default: '' }, lastDeviceId: { type: String, select: false, default: null }, + // OR-19: the contributor accepts the data-use notice right after verifying their email. + consent: { + acceptedAt: { type: Date, default: null }, + version: { type: String, default: null }, + }, connections: { github: { type: String, default: null }, linkedin: { type: String, default: null }, diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index d9fecf9..c8696ff 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -3,13 +3,15 @@ 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 { emailOnlySchema, loginSchema, resetPasswordSchema, signupSchema, verifyEmailSchema } from '../middlewares/validators/authSchemas.js' +import { consentSchema, declineConsentSchema, 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('/consent', authLimiter, validate({ body: consentSchema }), authController.acceptConsent) +router.post('/decline-consent', authLimiter, validate({ body: declineConsentSchema }), authController.declineConsent) 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) diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js index 56e2390..fa93d50 100644 --- a/backend/src/routes/userRoutes.js +++ b/backend/src/routes/userRoutes.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { userController } from '../controllers/userController.js' import { authenticate } from '../middlewares/authenticate.js' import { validate } from '../middlewares/validate.js' -import { updateMeSchema } from '../middlewares/validators/userSchemas.js' +import { acceptConsentSchema, updateMeSchema } from '../middlewares/validators/userSchemas.js' const router = Router() router.use(authenticate) router.get('/me', userController.me) router.patch('/me', validate({ body: updateMeSchema }), userController.updateMe) +router.post('/me/consent', validate({ body: acceptConsentSchema }), userController.acceptConsent) export default router diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index f820c7d..c2deaab 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -1,12 +1,12 @@ import bcrypt from 'bcrypt' import { createHmac, randomInt, timingSafeEqual } from 'node:crypto' -import { ROLES } from '../config/constants.js' +import { CONSENT_VERSION, ROLES } from '../config/constants.js' import { env } from '../config/env.js' import { userRepo } from '../repositories/userRepo.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 { signConsentToken, signToken, verifyConsentToken } 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". @@ -79,12 +79,36 @@ export const authService = { await userRepo.save(user) throw invalidCode() } - user.emailVerified = true + // A correct code only proves the email address. The account is not created (verified, able to log in + // or contribute) until the contributor accepts the consent notice: see completeSignup. clearOtp(user, 'email-verification') await userRepo.save(user) + return { consentToken: signConsentToken(user.id) } + }, + + async pendingSignup(consentToken) { + const userId = verifyConsentToken(consentToken) + const user = userId ? await userRepo.findById(userId) : null + if (!user || user.emailVerified || user.role !== ROLES.USER) throw badRequest('This sign-up step has expired. Verify your email again to continue.') + return user + }, + + /** Finishes sign-up: records the consent (date, time, version), activates the account and starts a session. */ + async completeSignup({ consentToken, version }) { + if (version !== CONSENT_VERSION) throw conflict('The consent notice was updated. Reload the page and read the current version.') + const user = await this.pendingSignup(consentToken) + user.emailVerified = true + user.consent = { acceptedAt: new Date(), version: CONSENT_VERSION } + await userRepo.save(user) return { user, token: signToken(user.id, user.authVersion || 0) } }, + /** Declining consent means no account: the unfinished sign-up is removed. */ + async declineSignup({ consentToken }) { + const user = await this.pendingSignup(consentToken) + await userRepo.remove(user._id) + }, + /** * Resend and password-reset both used to `await issueOtp(...)`, which calls out to Brevo. * An unknown email returned instantly (no user, nothing to await); a known email waited on diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js index 797ff8e..062339b 100644 --- a/backend/src/services/submissionService.js +++ b/backend/src/services/submissionService.js @@ -1,9 +1,9 @@ -import { MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js' +import { CONSENT_VERSION, MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js' import { env } from '../config/env.js' import { submissionRepo } from '../repositories/submissionRepo.js' import { categoryRepo } from '../repositories/categoryRepo.js' import { videoRepo } from '../repositories/videoRepo.js' -import { conflict, notFound, validationError } from '../utils/AppError.js' +import { AppError, conflict, notFound, validationError } from '../utils/AppError.js' import { sniffVideo } from '../utils/files.js' import { storageService } from './storage/index.js' @@ -27,6 +27,9 @@ export const submissionService = { * once in, stays exactly as submitted. */ async create(user, input, upload) { + if (user.role === ROLES.USER && user.consent?.version !== CONSENT_VERSION) { + throw new AppError(403, 'CONSENT_REQUIRED', 'Accept the consent notice before submitting a recording.') + } if (!upload) throw validationError({ recording: 'Attach your recording.' }) const kind = await sniffVideo(upload.path) if (!kind) throw validationError({ recording: 'That does not look like an MP4, WebM or MOV video.' }) diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index 5276caf..10e918a 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -1,10 +1,18 @@ -import { ROLES } from '../config/constants.js' +import { CONSENT_VERSION, ROLES } from '../config/constants.js' import { submissionRepo } from '../repositories/submissionRepo.js' import { userRepo } from '../repositories/userRepo.js' import { storageService } from './storage/index.js' import { conflict, forbidden, notFound } from '../utils/AppError.js' export const userService = { + /** Records the date and time the contributor accepted the consent notice (kept on the first acceptance of a version). */ + async acceptConsent(user, version) { + if (version !== CONSENT_VERSION) throw conflict('The consent notice was updated. Reload the page and read the current version.') + if (user.consent?.acceptedAt && user.consent.version === CONSENT_VERSION) return user + user.consent = { acceptedAt: new Date(), version: CONSENT_VERSION } + return userRepo.save(user) + }, + async updateConnections(user, connections) { // Only keys that were sent change; null disconnects. for (const key of ['github', 'linkedin']) { diff --git a/backend/src/utils/tokens.js b/backend/src/utils/tokens.js index af8de5d..51aba6d 100644 --- a/backend/src/utils/tokens.js +++ b/backend/src/utils/tokens.js @@ -21,3 +21,16 @@ const cookieOptions = () => ({ httpOnly: true, secure: env.COOKIE_SECURE, sameSi export const setAuthCookie = (res, token) => res.cookie(AUTH_COOKIE, token, { ...cookieOptions(), maxAge: env.JWT_EXPIRES_DAYS * DAY_MS }) export const clearAuthCookie = (res) => res.clearCookie(AUTH_COOKIE, cookieOptions()) + +// Short-lived proof that someone just entered a valid email code. It is not a login: the account +// is only finished (and a session started) once the consent notice is accepted (see authService.completeSignup). +const CONSENT_TOKEN_MINUTES = 30 +export const signConsentToken = (userId) => jwt.sign({ purpose: 'signup-consent' }, env.JWT_SECRET, { subject: String(userId), expiresIn: `${CONSENT_TOKEN_MINUTES}m`, algorithm: 'HS256' }) +export function verifyConsentToken(token) { + try { + const payload = jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] }) + return payload.purpose === 'signup-consent' && payload.sub ? payload.sub : null + } catch { + return null + } +} diff --git a/backend/tests/api-auth.test.js b/backend/tests/api-auth.test.js index 7dbb6f7..014b7c0 100644 --- a/backend/tests/api-auth.test.js +++ b/backend/tests/api-auth.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { after, before, describe, test } from 'node:test' +import { CONSENT_VERSION } from '../src/config/constants.js' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' @@ -26,7 +27,7 @@ describe('auth', () => { assert.equal(short.body.error.fields.password, 'Use at least 8 characters.') }) - test('signup sets an httpOnly cookie and never leaks the hash or accepts a role', async () => { + test('signup only creates an account after consent 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' }) @@ -38,11 +39,17 @@ describe('auth', () => { 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(verified.setCookie.length, 0) + assert.ok(verified.body.data.consentToken) + assert.equal((await c.post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) + assert.equal((await c.post('/auth/consent', { consentToken: 'garbage-garbage', accepted: true, version: CONSENT_VERSION })).status, 400) + const accepted = await c.post('/auth/consent', { consentToken: verified.body.data.consentToken, accepted: true, version: CONSENT_VERSION }) + assert.equal(accepted.status, 200) + assert.equal(accepted.body.data.role, 'user') + assert.equal(accepted.body.data.emailVerified, true) assert.equal(res.body.data.passwordHash, undefined) - assert.match(verified.setCookie[0], /HttpOnly/i) - assert.match(verified.setCookie[0], /SameSite=Lax/i) + assert.match(accepted.setCookie[0], /HttpOnly/i) + assert.match(accepted.setCookie[0], /SameSite=Lax/i) assert.equal((await c.get('/admin/stats')).status, 403) }) diff --git a/backend/tests/api-consent.test.js b/backend/tests/api-consent.test.js new file mode 100644 index 0000000..2879379 --- /dev/null +++ b/backend/tests/api-consent.test.js @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import { after, before, describe, test } from 'node:test' +import { Client, recordingForm, videoForm } from './helpers.js' +import { CONSENT_VERSION } from '../src/config/constants.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext({ after }) +let base, admin, createVerifiedUser, deliveredCodes + +before(async () => { + const context = await getContext() + ;({ base, admin, createVerifiedUser, deliveredCodes } = context) +}) + +const signupAndVerify = async (email) => { + const client = new Client(base) + const signup = await client.post('/auth/signup', { firstName: 'Lina', surname: 'Raza', email, password: 'password1' }) + assert.equal(signup.status, 201) + const verified = await client.post('/auth/verify-email', { + email, + code: deliveredCodes.get(`${email}:email-verification`), + }) + assert.equal(verified.status, 200) + return { client, token: verified.body.data.consentToken } +} + +describe('consent notice', () => { + test('creates the account only after consent and records the acceptance time', async () => { + const { client, token } = await signupAndVerify('lina@example.com') + assert.equal((await client.get('/users/me')).status, 401) + assert.equal((await client.post('/auth/login', { email: 'lina@example.com', password: 'password1' })).status, 401) + assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: false, version: CONSENT_VERSION })).status, 422) + assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: true, version: 'old' })).status, 409) + + const before = Date.now() + const accepted = await client.post('/auth/consent', { consentToken: token, accepted: true, version: CONSENT_VERSION }) + assert.equal(accepted.status, 200) + assert.equal(accepted.body.data.emailVerified, true) + assert.equal(accepted.body.data.consent.version, CONSENT_VERSION) + assert.ok(new Date(accepted.body.data.consent.acceptedAt).getTime() >= before - 1000) + assert.equal((await client.get('/users/me')).status, 200) + assert.equal((await new Client(base).post('/auth/login', { email: 'lina@example.com', password: 'password1' })).status, 200) + assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: true, version: CONSENT_VERSION })).status, 400) + }) + + test('declining consent discards the unfinished signup', async () => { + const { client, token } = await signupAndVerify('mina@example.com') + assert.equal((await client.post('/auth/decline-consent', { consentToken: token })).status, 204) + assert.equal((await new Client(base).post('/auth/login', { email: 'mina@example.com', password: 'password1' })).status, 401) + const retry = await new Client(base).post('/auth/signup', { + firstName: 'Mina', + surname: 'Ali', + email: 'mina@example.com', + password: 'password1', + }) + assert.equal(retry.status, 201) + }) + + test('legacy accounts without consent cannot submit until they accept', async () => { + const user = await createVerifiedUser({ firstName: 'Old', surname: 'Timer', email: 'old@example.com', consent: false }) + const profile = await user.get('/users/me') + assert.equal(profile.body.data.consent.acceptedAt, null) + + const category = await admin.post('/categories', { label: 'Consent greetings', tone: 'blue' }) + assert.equal(category.status, 201) + const upload = await admin.upload('/videos', videoForm({ + title: 'Consent hello', + categoryId: category.body.data.id, + status: 'published', + durationSec: '3', + })) + assert.equal(upload.status, 201) + const form = () => recordingForm({ + videoId: upload.body.data.id, + trimStart: '0', + trimEnd: '2.5', + duration: '3', + mirrored: 'false', + }) + + const blocked = await user.upload('/submissions', form()) + assert.equal(blocked.status, 403) + assert.equal(blocked.body.error.code, 'CONSENT_REQUIRED') + assert.equal((await user.post('/users/me/consent', { accepted: true, version: CONSENT_VERSION })).status, 200) + assert.equal((await user.upload('/submissions', form())).status, 201) + }) +}) diff --git a/backend/tests/apiContext.js b/backend/tests/apiContext.js index c84f1b6..7496ee9 100644 --- a/backend/tests/apiContext.js +++ b/backend/tests/apiContext.js @@ -1,4 +1,5 @@ import { Client } from './helpers.js' +import { CONSENT_VERSION } from '../src/config/constants.js' process.env.NODE_ENV = 'test' process.env.MONGODB_URI = process.env.MONGODB_URI_TEST || 'mongodb://placeholder/unused' @@ -85,7 +86,7 @@ async function createApiContext() { }) if (login.status !== 200) throw new Error(`Test admin login failed: ${login.status}`) - const createVerifiedUser = async ({ firstName, surname, email, password = 'password1' }) => { + const createVerifiedUser = async ({ firstName, surname, email, password = 'password1', consent = true }) => { const user = new Client(base) const signup = await user.post('/auth/signup', { firstName, surname, email, password }) if (signup.status !== 201) throw new Error(`Test user signup failed: ${signup.status}`) @@ -95,6 +96,18 @@ async function createApiContext() { code: deliveredCodes.get(`${normalizedEmail}:email-verification`), }) if (verified.status !== 200) throw new Error(`Test user verification failed: ${verified.status}`) + const accepted = await user.post('/auth/consent', { + consentToken: verified.body.data.consentToken, + accepted: true, + version: CONSENT_VERSION, + }) + if (accepted.status !== 200) throw new Error(`Test consent failed: ${accepted.status}`) + if (!consent) { + await mongoose.models.User.updateOne( + { email: normalizedEmail }, + { $set: { 'consent.acceptedAt': null, 'consent.version': null } }, + ) + } return user } diff --git a/frontend/README.md b/frontend/README.md index c3d2eba..d8362c9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -31,8 +31,9 @@ backend separately, or set `VITE_API_ORIGIN` to another API origin. | `/demo` | Public | Walkthrough video. | | `/faq` | Public | Frequently asked questions. | | `/login`, `/signup` | Signed out | Sign in or register; redirects signed-in users. | -| `/verify-email`, `/forgot-password` | Signed out | Email verification and password recovery. | -| `/home` | Signed in | Category overview. | +| `/verify-email`, `/signup/consent`, `/forgot-password` | Signed out | Email verification, consent before account creation, and password recovery. | +| `/consent` | Signed in | Accept the current contributor notice; required for older accounts without current consent. | +| `/home` | Signed in with current consent | Category overview. | | `/library/:categoryId` | Signed in | Videos in a category. | | `/watch?v=:videoId` | Signed in | Watch a reference video and record a take. | | `/watch/edit?v=:videoId` | Signed in | Review, trim, and submit the current take. | @@ -44,7 +45,13 @@ backend separately, or set `VITE_API_ORIGIN` to another API origin. Route declarations live in `src/App.jsx`; shared path names and URL builders live in `src/routes/appRoutes.js`. Auth guards in `src/components/RouteGuards.jsx` -redirect visitors who do not meet the route's access requirement. +redirect visitors who do not meet the route's access requirement and route +contributors without the current consent version through `/consent`. + +After email verification, the frontend holds the short-lived consent token in +the navigation state and offers accept or decline. Accepting creates and signs +in the account; declining discards the pending signup. The consent version and +acceptance timestamp are stored by the backend. ## Backend connection diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index 6e9b34a..4b9cfeb 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -36,13 +36,16 @@ page; they are not separate page screens. - `GuestOnly` allows signed-out visitors through auth pages and sends signed-in users to their original destination or `/home`. -- `RequireAuth` waits for the session check and redirects visitors to `/login`. +- `RequireAuth` waits for the session check, redirects visitors to `/login`, and + requires non-admin contributors to accept the current consent notice before + accessing protected library and recording routes. - `RequireAdmin` allows the admin role and otherwise redirects to `/home`. The route tree includes public landing, demo, and FAQ pages; signed-out login, -signup, email verification, and password recovery pages; signed-in home, library, -watch, editor, and profile pages; and admin dashboard, user, video, and category -pages. +signup, email verification, consent-before-account-creation, and password +recovery pages; a signed-in consent page for existing accounts; signed-in home, +library, watch, editor, and profile pages; and admin dashboard, user, video, and +category pages. ## State and ownership @@ -51,7 +54,7 @@ management library. | Provider | Responsibility and persistence | | --- | --- | -| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, logout, and connection updates. | +| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, consent completion/decline, logout, and connection updates. | | `LibraryProvider` | Loads categories, visible videos, and the current user's submissions; provides derived category, contribution, and cooldown state plus admin mutations. | | `RecordingProvider` | Holds unsubmitted recording blobs and trim/mirror edits in memory, keyed by video ID. These drafts are discarded on submit, replacement, user change, or tab close. | | `UploadQueueProvider` | Holds up to two submitted recordings in an in-memory queue (one uploading and one waiting). Failed uploads keep their slot for retry or discard; the queue is cleared when the signed-in user changes. | @@ -78,6 +81,12 @@ The client: - Converts API-relative media URLs to absolute URLs when `VITE_API_ORIGIN` is configured. +Email verification returns a short-lived consent token rather than a session. +The signup consent screen submits that token and the current notice version; +only successful acceptance activates and signs in the new account. Declining +discards the pending signup. Existing contributors without the current consent +version are routed to `/consent` before using protected contributor pages. + Vite proxies `/api` to `http://localhost:5000` for local development. The optional `VITE_API_ORIGIN` variable selects a separately hosted API; leave it empty when using the local proxy. `VITE_SUBMISSION_COOLDOWN_MS` sets the UI's diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a35054..1085f12 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,6 +18,7 @@ import Login from './pages/Login' import Player from './pages/Player' import ProfileDashboard from './pages/ProfileDashboard' import RecordingEditor from './pages/RecordingEditor' +import Consent from './pages/Consent' import Signup from './pages/Signup' import VerifyEmail from './pages/VerifyEmail' @@ -31,12 +32,14 @@ export default function App() { }> } /> } /> + } /> } /> } /> {/* Logged-in contributors */} }> + } /> } /> } /> } /> diff --git a/frontend/src/components/RouteGuards.jsx b/frontend/src/components/RouteGuards.jsx index a145a68..1d784c8 100644 --- a/frontend/src/components/RouteGuards.jsx +++ b/frontend/src/components/RouteGuards.jsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { Navigate, Outlet, useLocation } from 'react-router-dom' import { Button, PageState } from './ui' import { useAuth } from '../context/AuthContext' +import { consentGiven } from '../utils/consent' export function RequireAuth() { const { user, loading, sessionChecked, sessionError, ensureSession } = useAuth() @@ -17,6 +18,8 @@ export function RequireAuth() { >{sessionError} if (!sessionChecked || loading) return
Checking your session…
if (!user) return + // Contributors must accept the consent notice before using the library or recording. + if (user.role !== 'admin' && !consentGiven(user) && location.pathname !== '/consent') return return } diff --git a/frontend/src/context/AuthProvider.jsx b/frontend/src/context/AuthProvider.jsx index 7aef4f9..56eb777 100644 --- a/frontend/src/context/AuthProvider.jsx +++ b/frontend/src/context/AuthProvider.jsx @@ -41,13 +41,16 @@ export default function AuthProvider({ children }) { return nextUser }, []) const signup = useCallback((values) => api.auth.register(values), []) - const verifyEmail = useCallback(async (email, code) => { - const nextUser = await api.auth.verifyEmail(email, code) + // A valid code only returns a consent token; the account is created (and logged in) by completeSignup. + const verifyEmail = useCallback((email, code) => api.auth.verifyEmail(email, code), []) + const completeSignup = useCallback(async (consentToken, version) => { + const nextUser = await api.auth.completeSignup(consentToken, version) setUser(nextUser) setSessionChecked(true) setSessionError('') return nextUser }, []) + const declineSignup = useCallback((consentToken) => api.auth.declineSignup(consentToken), []) const logout = useCallback(async () => { try { await api.auth.logout() } finally { setUser(null) @@ -55,13 +58,16 @@ export default function AuthProvider({ children }) { setSessionError('') } }, []) + const acceptConsent = useCallback(async (version) => { + setUser(await api.auth.acceptConsent(version)) + }, []) const saveConnections = useCallback(async (connections) => { if (user) setUser(await api.auth.saveConnections(user.id, connections)) }, [user]) const value = useMemo( - () => ({ user, loading, sessionChecked, sessionError, ensureSession, isAdmin: user?.role === 'admin', login, signup, verifyEmail, logout, saveConnections }), - [user, loading, sessionChecked, sessionError, ensureSession, login, signup, verifyEmail, logout, saveConnections], + () => ({ user, loading, sessionChecked, sessionError, ensureSession, isAdmin: user?.role === 'admin', login, signup, verifyEmail, logout, saveConnections, acceptConsent, completeSignup, declineSignup }), + [user, loading, sessionChecked, sessionError, ensureSession, login, signup, verifyEmail, logout, saveConnections, acceptConsent, completeSignup, declineSignup], ) return {children} } diff --git a/frontend/src/pages/Consent.jsx b/frontend/src/pages/Consent.jsx new file mode 100644 index 0000000..e84c0f3 --- /dev/null +++ b/frontend/src/pages/Consent.jsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' +import AuthShell from '../components/AuthShell' +import { Alert, Button } from '../components/ui' +import { useAuth } from '../context/AuthContext' +import { paths } from '../routes/appRoutes' +import { CONSENT_VERSION, consentGiven, consentPoints } from '../utils/consent' + +/** + * mode="signup": shown right after a valid email code. The account does not exist yet; accepting creates it, + * declining discards the sign-up. mode="existing": for an older account that never accepted the current notice. + */ +export default function Consent({ mode = 'existing' }) { + const { user, acceptConsent, completeSignup, declineSignup, logout } = useAuth() + const location = useLocation() + const navigate = useNavigate() + const consentToken = location.state?.consentToken + const [agreed, setAgreed] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + if (mode === 'signup' && !consentToken) return + if (mode === 'existing' && (consentGiven(user) || user?.role === 'admin')) return + + const submit = async (event) => { + event.preventDefault() + if (!agreed) return + setBusy(true) + setError('') + try { + if (mode === 'signup') await completeSignup(consentToken, CONSENT_VERSION) + else await acceptConsent(CONSENT_VERSION) + navigate(paths.library, { replace: true }) + } catch (cause) { + setError(cause.message || 'We could not save your consent. Try again.') + setBusy(false) + } + } + + const decline = async () => { + setBusy(true) + setError('') + try { + if (mode === 'signup') { + await declineSignup(consentToken) + navigate(paths.signup, { replace: true }) + } else { + await logout() + } + } catch (cause) { + setError(cause.message || 'We could not complete that. Try again.') + setBusy(false) + } + } + + return +
+
+ {consentPoints.map((point) =>
+

{point.title}

+

{point.body}

+
)} +
+ + {error && {error}} + +
+ +
+} diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx index 86286a7..21e93b5 100644 --- a/frontend/src/pages/VerifyEmail.jsx +++ b/frontend/src/pages/VerifyEmail.jsx @@ -25,8 +25,8 @@ export default function VerifyEmail() { setError('') setBusy(true) try { - await verifyEmail(email, code) - navigate(paths.library, { replace: true }) + const { consentToken } = await verifyEmail(email, code) + navigate(paths.signupConsent, { replace: true, state: { consentToken } }) } catch (cause) { setError(cause.message || 'We could not verify that code.') setBusy(false) diff --git a/frontend/src/routes/appRoutes.js b/frontend/src/routes/appRoutes.js index 5bf4dc5..20333a3 100644 --- a/frontend/src/routes/appRoutes.js +++ b/frontend/src/routes/appRoutes.js @@ -18,6 +18,8 @@ export const paths = { login: '/login', signup: '/signup', verifyEmail: '/verify-email', + consent: '/consent', + signupConsent: '/signup/consent', forgotPassword: '/forgot-password', library: '/home', profile: '/profile', diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 8f173e8..f9f7eef 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -176,11 +176,15 @@ 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 }))) }, + // A valid code returns { consentToken }; the account is only created by completeSignup. + async verifyEmail(email, code) { return request(apiRoutes.auth.verifyEmail(), json('POST', { email, code })) }, + async completeSignup(consentToken, version) { return normalize(await request(apiRoutes.auth.consent(), json('POST', { consentToken, accepted: true, version }))) }, + async declineSignup(consentToken) { await request(apiRoutes.auth.declineConsent(), json('POST', { consentToken })) }, 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 acceptConsent(version) { return normalize(await request(apiRoutes.users.consent(), json('POST', { accepted: true, version }))) }, 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 4730d34..52e0791 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -19,6 +19,8 @@ export const apiRoutes = { logout: () => `${API_BASE}/auth/logout`, session: () => `${API_BASE}/auth/session`, verifyEmail: () => `${API_BASE}/auth/verify-email`, + consent: () => `${API_BASE}/auth/consent`, + declineConsent: () => `${API_BASE}/auth/decline-consent`, resendVerification: () => `${API_BASE}/auth/resend-verification`, forgotPassword: () => `${API_BASE}/auth/forgot-password`, resetPassword: () => `${API_BASE}/auth/reset-password`, @@ -26,6 +28,7 @@ export const apiRoutes = { users: { me: () => `${API_BASE}/users/me`, + consent: () => `${API_BASE}/users/me/consent`, list: () => `${API_BASE}/admin/users`, update: (id) => `${API_BASE}/admin/users/${id}`, remove: (id) => `${API_BASE}/admin/users/${id}`, diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 12c1b2d..6088ec5 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -14,6 +14,11 @@ .auth-submit { @apply mt-8; } .auth-footer { @apply mt-7 text-center text-base text-muted; } .auth-footer a { @apply font-semibold text-accent-ink underline underline-offset-4 decoration-2 decoration-transparent hover:decoration-current; } + .consent-notice { @apply mt-6 max-h-72 space-y-4 overflow-y-auto rounded-xl border border-line bg-page p-4 text-sm text-muted; } + .consent-notice h2 { @apply text-sm font-semibold text-ink; } + .consent-notice p { @apply mt-1; } + .consent-check { @apply my-5 flex cursor-pointer items-start gap-3 text-sm text-ink; } + .consent-check input { @apply mt-0.5 size-4 shrink-0 accent-[var(--color-accent)]; } .auth-dev { @apply mt-8 rounded-xl border border-dashed border-line-strong p-3 text-sm text-faint; } @media (max-width: 767px) { diff --git a/frontend/src/utils/consent.js b/frontend/src/utils/consent.js new file mode 100644 index 0000000..0ec4c09 --- /dev/null +++ b/frontend/src/utils/consent.js @@ -0,0 +1,13 @@ +/** Must match CONSENT_VERSION on the backend. Bump both when the notice changes materially. */ +export const CONSENT_VERSION = '2026-10' + +export const consentGiven = (user) => user?.consent?.version === CONSENT_VERSION && Boolean(user?.consent?.acceptedAt) + +export const consentPoints = [ + { title: 'What your recordings are for', body: 'Your recordings are used to build the SignPAK Pakistan Sign Language (PSL) dataset and to train and test the sign recognition model. Each submission is linked to your account and the reference video, and stored using the storage service configured for this project (local storage or Google Drive).' }, + { title: 'Your video shows you', body: 'A recording of your hands, face and upper body can identify you. Only record what you are comfortable sharing, and make sure nobody else appears in the frame.' }, + { title: 'Non-commercial use', body: 'This is a non-profit, student-led project. Your recordings are not sold or shared with advertisers. When Google Drive storage is configured, Google processes and stores recordings as a service provider.' }, + { title: 'No watching or editing after submitting', body: 'Once submitted, a recording can no longer be watched or edited by you. You can ask the site admins through the contact form to delete your account and recordings.' }, + { title: 'Models already trained', body: 'If a recording has already been used to train a released model, it remains part of that model even after the file is deleted.' }, + { title: 'Security', body: 'We take reasonable security measures. As a university research project we cannot accept full liability in the unlikely event of a breach.' }, +] From 47ccbc09f330f1b208eb5149d6d456ec15b38cb8 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Thu, 15 Oct 2026 00:16:17 +0500 Subject: [PATCH 2/4] further improvements and updates --- backend/README.md | 7 ++++--- backend/docs/API.md | 6 ++++-- backend/src/docs/openapi.js | 1 + backend/src/models/Submission.js | 2 ++ backend/src/repositories/submissionRepo.js | 4 ++-- backend/src/services/submissionService.js | 6 +++--- backend/tests/api-submissions.test.js | 1 + frontend/docs/architecture.md | 4 +++- frontend/src/context/LibraryProvider.jsx | 10 ++++++++-- frontend/src/pages/Faq.jsx | 2 +- frontend/src/pages/RecordingEditor.jsx | 2 +- frontend/src/services/api.js | 2 +- frontend/test/UploadQueueProvider.test.jsx | 20 +++++++++++++++++++- 13 files changed, 50 insertions(+), 17 deletions(-) diff --git a/backend/README.md b/backend/README.md index 6a7c12f..d60fbcc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -108,9 +108,10 @@ mounted outside production. - Reference and demo-video uploads accept browser-reported `width` and `height` in pixels, each from 0 to 16,384. Zero represents unknown dimensions; these values are metadata for responsive playback, not server-side media probes. -- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair. - Atomic claiming prevents simultaneous duplicate submissions; failed processing - releases the claim. +- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair, + starting when the server accepts an upload for processing. Atomic claiming + prevents simultaneous duplicate submissions; failed processing releases the + claim. - Contact delivery uses Web3Forms. A signed-in account can send from its own email; quotas are one per account per UTC day and 25 per UTC day globally by default. Abusive attempts can temporarily restrict a device or IP. diff --git a/backend/docs/API.md b/backend/docs/API.md index c5f6cea..9ba95d6 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -170,8 +170,10 @@ administrator can recover by removing a restriction. - Deleting a category unassigns videos rather than deleting them. Deleting a reference video keeps submissions associated with that video on record. - A contributor may submit the same video again after the - `SUBMISSION_COOLDOWN_MS` cooldown (default 30 seconds). Concurrent submissions - for the same account/video pair are atomically limited. + `SUBMISSION_COOLDOWN_MS` cooldown (default 30 seconds), measured from when the + server starts processing the upload request. The submission response includes + `cooldownStartedAt` so the client countdown uses the same timestamp. Concurrent + submissions for the same account/video pair are atomically limited. For the detailed OpenAPI schemas and examples, use the raw OpenAPI document or the development Swagger UI. diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index b906a0d..236c29e 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -259,6 +259,7 @@ export const openapi = { mirrored: { type: 'boolean' }, duration: { type: 'number' }, submittedAt: { type: 'string', format: 'date-time' }, + cooldownStartedAt: { type: 'string', format: 'date-time', description: 'When the server accepted the upload for processing and claimed the per-video cooldown.' }, size: { type: 'integer' }, mimeType: { type: 'string', enum: ['video/mp4', 'video/webm', 'video/quicktime'] }, archivePath: { type: 'string', nullable: true, description: 'Included for admins only.' }, diff --git a/backend/src/models/Submission.js b/backend/src/models/Submission.js index 05ad542..f6e3f8b 100644 --- a/backend/src/models/Submission.js +++ b/backend/src/models/Submission.js @@ -10,12 +10,14 @@ const submissionSchema = new mongoose.Schema({ mirrored: { type: Boolean, default: false }, duration: { type: Number, required: true, min: 0 }, recording: { type: storedFileSchema, required: true }, + cooldownStartedAt: { type: Date }, }, { timestamps: true, toJSON: jsonOptions((doc, ret) => { ret.userId = String(ret.user) ret.videoId = String(ret.video) ret.submittedAt = ret.createdAt + ret.cooldownStartedAt = ret.cooldownStartedAt || ret.createdAt ret.size = ret.recording?.size ?? 0 ret.mimeType = ret.recording?.mimeType ret.archivePath = ret.recording?.archivePath ?? null diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js index a1e424e..4c49306 100644 --- a/backend/src/repositories/submissionRepo.js +++ b/backend/src/repositories/submissionRepo.js @@ -40,9 +40,9 @@ export const submissionRepo = { { $set: { lockedUntil: new Date(now.getTime() + cooldownMs) } }, { upsert: true }, ) - return true + return now } catch (error) { - if (isDuplicateKeyError(error)) return false + if (isDuplicateKeyError(error)) return null throw error } }, diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js index 062339b..b5fd3d3 100644 --- a/backend/src/services/submissionService.js +++ b/backend/src/services/submissionService.js @@ -40,8 +40,8 @@ export const submissionService = { const category = await categoryRepo.findById(video.category) if (!category) throw notFound('That video category is not available.') - 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 cooldownStartedAt = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS) + if (!cooldownStartedAt) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id)) let recording try { @@ -55,7 +55,7 @@ export const submissionService = { }) return await submissionRepo.create({ user: user._id, video: video._id, trimStart: input.trimStart, trimEnd: input.trimEnd, - mirrored: input.mirrored, duration: input.duration, recording, + mirrored: input.mirrored, duration: input.duration, recording, cooldownStartedAt, }) } catch (error) { await storageService.remove(recording) diff --git a/backend/tests/api-submissions.test.js b/backend/tests/api-submissions.test.js index d8f5ffc..07cb4f9 100644 --- a/backend/tests/api-submissions.test.js +++ b/backend/tests/api-submissions.test.js @@ -77,6 +77,7 @@ describe('submissions and the cooldown rule', () => { assert.equal(submission.videoId, video1.id) assert.equal(submission.mirrored, true) assert.equal(submission.trimEnd, 2.5) + assert.ok(Date.parse(submission.cooldownStartedAt) <= Date.parse(submission.submittedAt)) assert.equal(submission.recording, undefined) assert.equal(filesIn('commons').length, 1) const mine = await contributor.get('/submissions') diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index 4b9cfeb..ec374fd 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -105,7 +105,9 @@ to the player immediately; a page-level toaster reports waiting, active, failed, and completed uploads. Failed uploads retain their queue slot until retried or discarded. Queue data is memory-only, and a `beforeunload` prompt warns if the tab is closed or reloaded while work remains. The backend stores contributor -recordings and metadata; it does not physically trim or transcode them. +recordings and metadata; it does not physically trim or transcode them. The +per-video cooldown starts when the server accepts an upload for processing, and +the API's `cooldownStartedAt` timestamp drives the matching client countdown. ### Reference-video cache diff --git a/frontend/src/context/LibraryProvider.jsx b/frontend/src/context/LibraryProvider.jsx index 6b31b1b..9d4df5f 100644 --- a/frontend/src/context/LibraryProvider.jsx +++ b/frontend/src/context/LibraryProvider.jsx @@ -105,8 +105,14 @@ export default function LibraryProvider({ children }) { const hasSubmission = (videoId) => contributedIds.has(videoId) const submissionsFor = (videoId) => mySubmissions.filter((item) => item.videoId === videoId).sort((a, b) => b.submittedAt - a.submittedAt) const submissionCount = (videoId) => submissionsFor(videoId).length - /** Timestamp of this user's most recent submission for a video, or null. Drives the record-again cooldown. */ - const lastSubmissionAt = (videoId) => submissionsFor(videoId)[0]?.submittedAt ?? null + /** Server timestamp for the active record-again cooldown, or null for an older submission. */ + const lastSubmissionAt = (videoId) => { + const latestStart = submissionsFor(videoId).reduce((latest, item) => { + const startedAt = item.cooldownStartedAt ?? item.submittedAt + return startedAt > latest ? startedAt : latest + }, 0) + return latestStart || null + } const nextVideo = (video) => { const list = videosIn(video.categoryId) diff --git a/frontend/src/pages/Faq.jsx b/frontend/src/pages/Faq.jsx index f87ef74..638891e 100644 --- a/frontend/src/pages/Faq.jsx +++ b/frontend/src/pages/Faq.jsx @@ -16,7 +16,7 @@ const faqs = [ }, { q: 'Can I record the same video more than once?', - a: 'Yes, and it helps: more varied recordings of the same sign make the resulting dataset stronger. After you submit a recording there is a short 30-second pause before you can submit another one for that same video, mainly to make sure each take is a genuine, separate recording rather than a rapid re-upload of the same clip.', + a: 'Yes, and it helps: more varied recordings of the same sign make the resulting dataset stronger. Once an upload is accepted, there is a 30-second pause before you can submit another recording for that same video, mainly to make sure each take is a genuine, separate recording rather than a rapid re-upload of the same clip.', }, { q: 'Can I watch, edit or delete a recording after I submit it?', diff --git a/frontend/src/pages/RecordingEditor.jsx b/frontend/src/pages/RecordingEditor.jsx index ca92525..0b7ea48 100644 --- a/frontend/src/pages/RecordingEditor.jsx +++ b/frontend/src/pages/RecordingEditor.jsx @@ -230,7 +230,7 @@ function Editor({ video, recording, queueFull, onEdit, onSubmit }) { {confirming ? (
-

Submit this recording? It uploads in the background, so you can keep browsing and record the next video. Once submitted it can't be watched, edited or deleted, but you can record this video again after a short cooldown.

+

Submit this recording? It uploads in the background, so you can keep browsing and record the next video. Once submitted it can't be watched, edited or deleted, but you can record this video again after a 30-second cooldown once the upload is accepted.

+ {!prepared &&

{file ? 'Remove sound, trim if needed, then choose “Apply changes” to enable the upload.' : 'Select a file to enable the upload action.'}

}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index b7a2e0a..01f141b 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -213,7 +213,7 @@ export const api = { const body = new FormData() body.append('video', file) const posterBlob = await toBlob(poster) - if (posterBlob) body.append('poster', posterBlob, 'poster.jpg') + if (posterBlob) body.append('poster', posterBlob, poster?.name || 'poster.jpg') body.append('title', title) body.append('categoryId', categoryId || '') body.append('status', status) diff --git a/frontend/test/AdminVideos.test.jsx b/frontend/test/AdminVideos.test.jsx new file mode 100644 index 0000000..2c97031 --- /dev/null +++ b/frontend/test/AdminVideos.test.jsx @@ -0,0 +1,79 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AdminVideos from '../src/pages/admin/AdminVideos' + +const { addVideo, useLibrary } = vi.hoisted(() => ({ + addVideo: vi.fn(), + useLibrary: vi.fn(), +})) + +vi.mock('../src/context/LibraryContext', () => ({ useLibrary })) +vi.mock('../src/pages/admin/AdminDemoVideo', () => ({ default: () => null })) +vi.mock('../src/components/VideoPrep', () => ({ + default: ({ onReady }) => ( + + ), +})) + +const library = () => ({ videos: [], categories: [], addVideo }) + +function renderAdminVideos() { + useLibrary.mockReturnValue(library()) + return render() +} + +function chooseVideo() { + const input = screen.getByText('Choose a video').closest('label').querySelector('input') + fireEvent.change(input, { + target: { files: [new File(['video'], 'hello.mp4', { type: 'video/mp4' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Finish video prep' })) +} + +describe('AdminVideos poster selection', () => { + beforeEach(() => { + vi.clearAllMocks() + addVideo.mockResolvedValue({}) + }) + + afterEach(cleanup) + + it('requires an explicit poster image or auto-frame choice', async () => { + renderAdminVideos() + chooseVideo() + + expect(screen.getByRole('button', { name: 'Upload video' }).disabled).toBe(true) + + const autoFrame = screen.getByRole('checkbox', { name: /auto-generated frame/i }) + fireEvent.click(autoFrame) + expect(screen.getByRole('button', { name: 'Upload video' }).disabled).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Upload video' })) + + await waitFor(() => expect(addVideo).toHaveBeenCalledOnce()) + expect(addVideo.mock.calls[0][0].poster).toBe('data:image/jpeg;base64,auto-frame') + }) + + it('uploads a selected poster image instead of the auto-generated frame', async () => { + renderAdminVideos() + chooseVideo() + const poster = new File(['poster image'], 'custom.png', { type: 'image/png' }) + fireEvent.change(screen.getByLabelText(/Poster image/), { target: { files: [poster] } }) + + expect(screen.getByRole('button', { name: 'Upload video' }).disabled).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Upload video' })) + + await waitFor(() => expect(addVideo).toHaveBeenCalledOnce()) + expect(addVideo.mock.calls[0][0].poster).toBe(poster) + }) +}) From bae46725e3e39b1407a622ea47e4212b965b5880 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Thu, 15 Oct 2026 00:35:49 +0500 Subject: [PATCH 4/4] v1.0.6 and v1.0.3 --- backend/package-lock.json | 4 ++-- backend/package.json | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 394e50f..0f374a2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "dependencies": { "@getbrevo/brevo": "^6.0.3", diff --git a/backend/package.json b/backend/package.json index 9079e7d..b47ef68 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.2", + "version": "1.0.3", "description": "SignPakCommons API: Express, Node.js and MongoDB", "main": "server.js", "scripts": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 766f9df..c936c1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", diff --git a/frontend/package.json b/frontend/package.json index 70b697a..f443496 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.5", + "version": "1.0.6", "type": "module", "engines": { "node": ">=20.19.0"