diff --git a/backend/src/app.js b/backend/src/app.js index 9709bbf..74facf6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -9,6 +9,7 @@ import { API_PREFIX } from './config/constants.js' import { env } from './config/env.js' import { openapi } from './docs/openapi.js' import { errorHandler, notFoundHandler } from './middlewares/errorHandler.js' +import { enforceAccessRestrictions } from './middlewares/enforceAccessRestrictions.js' import { apiLimiter } from './middlewares/rateLimiter.js' import { verifyOrigin } from './middlewares/verifyOrigin.js' import routes from './routes/index.js' @@ -47,7 +48,7 @@ app.get('/api-docs.json', (req, res) => res.json(openapi)) app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) -app.use(API_PREFIX, apiLimiter, verifyOrigin, routes) +app.use(API_PREFIX, apiLimiter, verifyOrigin, enforceAccessRestrictions, routes) app.use(notFoundHandler) app.use(errorHandler) diff --git a/backend/src/controllers/accessRestrictionController.js b/backend/src/controllers/accessRestrictionController.js new file mode 100644 index 0000000..4f97799 --- /dev/null +++ b/backend/src/controllers/accessRestrictionController.js @@ -0,0 +1,9 @@ +import { accessRestrictionService } from '../services/accessRestrictionService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' + +export const accessRestrictionController = { + list: asyncHandler(async (req, res) => ok(res, await accessRestrictionService.list())), + create: asyncHandler(async (req, res) => created(res, await accessRestrictionService.create(req.body, req.user._id))), + remove: asyncHandler(async (req, res) => { await accessRestrictionService.remove(req.params.id); noContent(res) }), +} \ No newline at end of file diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 6ae332c..440ed46 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -6,12 +6,14 @@ 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) }), login: asyncHandler(async (req, res) => { const { user, token } = await authService.login(req.body) + await authService.recordDevice(user, req.get('X-Device-ID')) setAuthCookie(res, token) ok(res, user) }), diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index dc2bd1f..d6be7dc 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -1,9 +1,11 @@ import { userService } from '../services/userService.js' import { asyncHandler } from '../utils/asyncHandler.js' -import { ok } from '../utils/response.js' +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))), 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 49f1603..d751a16 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -74,7 +74,8 @@ export const openapi = { type: 'object', properties: { id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' }, - role: { type: 'string', enum: ['user', 'admin'] }, connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, + role: { type: 'string', enum: ['user', 'admin'] }, status: { type: 'string', enum: ['active', 'suspended'] }, statusReason: { type: 'string' }, + connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, }, }, AuthInput: { @@ -135,6 +136,16 @@ export const openapi = { }, '/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 } } }, '/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } }, + '/admin/users/{id}': { + parameters: [idParameter], + patch: { tags: ['Admin'], summary: 'Activate or suspend a user', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['active', 'suspended'] }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 200: { description: 'Updated user' }, 403: errorResponse, 404: errorResponse } }, + delete: { tags: ['Admin'], summary: 'Delete a user and their submitted recordings', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 403: errorResponse, 404: errorResponse } }, + }, + '/admin/restrictions': { + get: { tags: ['Admin'], summary: 'List IP and device restrictions', security: adminSecurity, responses: { 200: { description: 'Restrictions' } } }, + post: { tags: ['Admin'], summary: 'Restrict an IP address or device ID', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['type', 'value'], properties: { type: { type: 'string', enum: ['ip', 'device'] }, value: { type: 'string' }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 201: { description: 'Restriction created' }, 422: errorResponse } }, + }, + '/admin/restrictions/{id}': { parameters: [idParameter], delete: { tags: ['Admin'], summary: 'Remove an IP or device restriction', security: adminSecurity, responses: { 204: { description: 'Removed' }, 404: errorResponse } } }, '/admin/stats': { get: { tags: ['Admin'], summary: 'Get dashboard statistics', security: adminSecurity, parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], responses: { 200: { description: 'Statistics' }, 403: errorResponse } } }, '/admin/messages': { get: { tags: ['Admin'], summary: 'List contact messages', security: adminSecurity, parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], responses: { 200: { description: 'Messages' }, 403: errorResponse } } }, }, diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js index 212e74b..9e3b9f6 100644 --- a/backend/src/middlewares/authenticate.js +++ b/backend/src/middlewares/authenticate.js @@ -15,12 +15,14 @@ async function loadUser(req) { export const authenticate = asyncHandler(async (req, res, next) => { const user = await loadUser(req) if (!user) throw unauthorized() + if (user.status === 'suspended') throw unauthorized('This account is suspended.') req.user = user next() }) /** Attaches req.user when there is a valid session, but lets anonymous visitors through. */ export const optionalAuthenticate = asyncHandler(async (req, res, next) => { - req.user = (await loadUser(req)) || null + const user = await loadUser(req) + req.user = user?.status === 'active' ? user : null next() }) diff --git a/backend/src/middlewares/enforceAccessRestrictions.js b/backend/src/middlewares/enforceAccessRestrictions.js new file mode 100644 index 0000000..dccdc50 --- /dev/null +++ b/backend/src/middlewares/enforceAccessRestrictions.js @@ -0,0 +1,13 @@ +import { accessRestrictionService } from '../services/accessRestrictionService.js' +import { forbidden } from '../utils/AppError.js' +import { asyncHandler } from '../utils/asyncHandler.js' + +export const enforceAccessRestrictions = asyncHandler(async (req, res, next) => { + // Admin routes stay reachable so an administrator can remove a mistaken restriction. + if (req.path === '/admin' || req.path.startsWith('/admin/')) return next() + const deviceId = req.get('X-Device-ID')?.slice(0, 128) + if (await accessRestrictionService.isRequestRestricted(req.ip, deviceId)) { + throw forbidden('Access from this network or device has been restricted.') + } + next() +}) \ No newline at end of file diff --git a/backend/src/middlewares/validators/adminSchemas.js b/backend/src/middlewares/validators/adminSchemas.js new file mode 100644 index 0000000..572c6d2 --- /dev/null +++ b/backend/src/middlewares/validators/adminSchemas.js @@ -0,0 +1,16 @@ +import { isIP } from 'node:net' +import { z } from 'zod' + +export const updateUserStatusSchema = z.object({ + status: z.enum(['active', 'suspended']), + reason: z.string().trim().max(500, 'Keep the reason under 500 characters.').optional().default(''), +}) + +export const accessRestrictionSchema = z.object({ + type: z.enum(['ip', 'device']), + value: z.string().trim().min(1, 'Enter an IP address or device ID.').max(128), + reason: z.string().trim().max(500, 'Keep the reason under 500 characters.').optional().default(''), +}).superRefine(({ type, value }, context) => { + const valid = type === 'ip' ? isIP(value) !== 0 : /^[A-Za-z0-9._:-]{16,128}$/.test(value) + if (!valid) context.addIssue({ code: 'custom', path: ['value'], message: type === 'ip' ? 'Enter a valid IPv4 or IPv6 address.' : 'Enter a valid device ID.' }) +}) \ No newline at end of file diff --git a/backend/src/models/AccessRestriction.js b/backend/src/models/AccessRestriction.js new file mode 100644 index 0000000..dcab8b0 --- /dev/null +++ b/backend/src/models/AccessRestriction.js @@ -0,0 +1,18 @@ +import mongoose from 'mongoose' +import { jsonOptions } from '../utils/mongoose.js' + +const accessRestrictionSchema = new mongoose.Schema({ + type: { type: String, enum: ['ip', 'device'], required: true }, + 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 }, +}, { + timestamps: true, + versionKey: false, + toJSON: jsonOptions((doc, ret) => { delete ret.identifierHash; return ret }), +}) + +accessRestrictionSchema.index({ type: 1, identifierHash: 1 }, { unique: true }) + +export const AccessRestriction = mongoose.model('AccessRestriction', accessRestrictionSchema) \ No newline at end of file diff --git a/backend/src/models/User.js b/backend/src/models/User.js index cad2e4a..4903105 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -8,13 +8,16 @@ const userSchema = new mongoose.Schema({ surname: { type: String, required: true, trim: true, maxlength: 60 }, passwordHash: { type: String, required: true, 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: '' }, + lastDeviceId: { type: String, select: false, default: null }, connections: { github: { type: String, default: null }, linkedin: { type: String, default: null }, }, }, { timestamps: true, - toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; return ret }), + toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; delete ret.lastDeviceId; return ret }), }) export const User = mongoose.model('User', userSchema) diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js index bc1f6d5..a1e424e 100644 --- a/backend/src/repositories/submissionRepo.js +++ b/backend/src/repositories/submissionRepo.js @@ -9,6 +9,14 @@ export const submissionRepo = { recent: (limit) => Submission.find().sort({ createdAt: -1 }).limit(limit), countForPair: (userId, videoId) => Submission.countDocuments({ user: userId, video: videoId }), count: () => Submission.countDocuments(), + async removeForUser(userId) { + const submissions = await Submission.find({ user: userId }).select('recording') + await Promise.all([ + Submission.deleteMany({ user: userId }), + SubmissionCooldown.deleteMany({ user: userId }), + ]) + return submissions.map((submission) => submission.recording) + }, // Just the timestamps, for bucketing into days. createdSince: (date) => Submission.find({ createdAt: { $gte: date } }).select('createdAt').lean(), countsByVideo: () => Submission.aggregate([{ $group: { _id: '$video', count: { $sum: 1 } } }]), diff --git a/backend/src/repositories/userRepo.js b/backend/src/repositories/userRepo.js index 5bee158..f38e091 100644 --- a/backend/src/repositories/userRepo.js +++ b/backend/src/repositories/userRepo.js @@ -9,6 +9,11 @@ export const userRepo = { findManyByIds: (ids) => User.find({ _id: { $in: ids } }), create: (data) => User.create(data), save: (doc) => doc.save(), - list: ({ role } = {}) => User.find(role ? { role } : {}).sort({ createdAt: 1 }), + list: ({ role, includeDeviceIds = false } = {}) => { + const query = User.find(role ? { role } : {}).sort({ createdAt: 1 }) + return includeDeviceIds ? query.select('+lastDeviceId') : query + }, countByRole: (role) => User.countDocuments({ role }), + countActiveAdmins: () => User.countDocuments({ role: 'admin', status: 'active' }), + remove: (id) => User.deleteOne({ _id: id }), } diff --git a/backend/src/routes/adminRoutes.js b/backend/src/routes/adminRoutes.js index ef507e7..9ba2e44 100644 --- a/backend/src/routes/adminRoutes.js +++ b/backend/src/routes/adminRoutes.js @@ -1,17 +1,25 @@ import { Router } from 'express' import { ROLES } from '../config/constants.js' import { adminController } from '../controllers/adminController.js' +import { accessRestrictionController } from '../controllers/accessRestrictionController.js' import { contactController } from '../controllers/contactController.js' import { userController } from '../controllers/userController.js' import { authenticate } from '../middlewares/authenticate.js' import { requireRole } from '../middlewares/requireRole.js' import { validate } from '../middlewares/validate.js' import { limitQuerySchema, statsQuerySchema } from '../middlewares/validators/miscSchemas.js' +import { accessRestrictionSchema, updateUserStatusSchema } from '../middlewares/validators/adminSchemas.js' +import { idParams } from '../middlewares/validators/common.js' const router = Router() router.use(authenticate, requireRole(ROLES.ADMIN)) router.get('/users', userController.list) +router.patch('/users/:id', validate({ params: idParams, body: updateUserStatusSchema }), userController.updateStatus) +router.delete('/users/:id', validate({ params: idParams }), userController.remove) +router.get('/restrictions', accessRestrictionController.list) +router.post('/restrictions', validate({ body: accessRestrictionSchema }), accessRestrictionController.create) +router.delete('/restrictions/:id', validate({ params: idParams }), accessRestrictionController.remove) router.get('/stats', validate({ query: statsQuerySchema }), adminController.stats) router.get('/messages', validate({ query: limitQuerySchema }), contactController.list) diff --git a/backend/src/services/accessRestrictionService.js b/backend/src/services/accessRestrictionService.js new file mode 100644 index 0000000..d83efa9 --- /dev/null +++ b/backend/src/services/accessRestrictionService.js @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto' +import { AccessRestriction } from '../models/AccessRestriction.js' +import { conflict, notFound } from '../utils/AppError.js' + +const hash = (value) => createHash('sha256').update(value.trim().toLowerCase()).digest('hex') + +function hint(type, value) { + if (type === 'ip') { + const parts = value.split('.') + if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}.x` + return `${value.slice(0, 12)}...` + } + return `${value.slice(0, 8)}...${value.slice(-4)}` +} + +export const accessRestrictionService = { + list: () => AccessRestriction.find().sort({ createdAt: -1 }), + + async create({ type, value, reason }, adminId) { + try { + return await AccessRestriction.create({ + type, + identifierHash: hash(value), + identifierHint: hint(type, value), + reason, + createdBy: adminId, + }) + } catch (error) { + if (error?.code === 11000) throw conflict('That identifier is already restricted.', { value: 'That identifier is already restricted.' }) + throw error + } + }, + + async remove(id) { + const result = await AccessRestriction.deleteOne({ _id: id }) + if (!result.deletedCount) throw notFound('That restriction does not exist.') + }, + + 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 }) + }, +} \ No newline at end of file diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index 1b37bbb..5312ce4 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -30,9 +30,16 @@ export const authService = { 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) } }, + async recordDevice(user, deviceId) { + if (!/^[A-Za-z0-9._:-]{16,128}$/.test(deviceId || '')) return user + user.lastDeviceId = deviceId + return userRepo.save(user) + }, + /** Admin accounts come from environment variables, never from the signup form. Safe to run on every boot. */ async ensureAdminFromEnv() { const existing = await userRepo.findByEmail(env.ADMIN_EMAIL) diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index 79269e9..5276caf 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -1,5 +1,8 @@ import { 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 = { async updateConnections(user, connections) { @@ -11,5 +14,32 @@ export const userService = { }, listContributors: () => userRepo.list({ role: ROLES.USER }), - listAll: () => userRepo.list(), + async listAll() { + const users = await userRepo.list({ includeDeviceIds: true }) + return users.map((user) => ({ ...user.toJSON(), lastDeviceId: user.lastDeviceId })) + }, + + async updateStatus(id, actor, { status, reason = '' }) { + const user = await userRepo.findById(id) + if (!user) throw notFound('That user does not exist.') + if (user.id === actor.id) throw forbidden('You cannot suspend your own account.') + if (user.role === ROLES.ADMIN && user.status === 'active' && status === 'suspended' && await userRepo.countActiveAdmins() <= 1) { + throw conflict('The last active administrator cannot be suspended.') + } + user.status = status + user.statusReason = status === 'suspended' ? reason : '' + return userRepo.save(user) + }, + + async remove(id, actor) { + const user = await userRepo.findById(id) + if (!user) throw notFound('That user does not exist.') + if (user.id === actor.id) throw forbidden('You cannot delete your own account.') + if (user.role === ROLES.ADMIN && user.status === 'active' && await userRepo.countActiveAdmins() <= 1) { + throw conflict('The last active administrator cannot be deleted.') + } + const recordings = await submissionRepo.removeForUser(user._id) + await userRepo.remove(user._id) + await Promise.all(recordings.map((recording) => storageService.remove(recording))) + }, } diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index f9d3aa1..8377088 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -6,6 +6,8 @@ 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 { Submission } = await import('../src/models/Submission.js') +const { SubmissionCooldown } = await import('../src/models/SubmissionCooldown.js') const { default: mongoose } = await import('mongoose') let server, memory, base, admin, contributor, other @@ -400,3 +402,62 @@ describe('admin stats and contact', () => { assert.deepEqual(filesIn('tmp'), []) }) }) + +describe('admin user controls and access restrictions', () => { + test('suspending a user blocks its existing session and login; reactivation restores access', async () => { + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const selfId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'admin@signpak.test').id + assert.equal((await admin.patch(`/admin/users/${selfId}`, { status: 'suspended' })).status, 403) + const suspended = await admin.patch(`/admin/users/${otherId}`, { status: 'suspended', reason: 'Repeated abuse' }) + assert.equal(suspended.status, 200) + assert.equal(suspended.body.data.status, 'suspended') + assert.equal(suspended.body.data.statusReason, 'Repeated abuse') + assert.equal((await other.get('/users/me')).status, 401) + assert.equal((await new Client(base).post('/auth/login', { email: 'omar@example.com', password: 'password1' })).status, 401) + assert.equal((await admin.patch(`/admin/users/${otherId}`, { status: 'active' })).body.data.status, 'active') + assert.equal((await other.get('/users/me')).status, 200) + }) + + test('deleting a user removes their submissions, cooldowns, and stored recording', async () => { + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const recording = (await Submission.findOne({ user: otherId })).recording + assert.ok(await SubmissionCooldown.countDocuments({ user: otherId })) + assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 204) + assert.equal(await Submission.countDocuments({ user: otherId }), 0) + assert.equal(await SubmissionCooldown.countDocuments({ user: otherId }), 0) + await assert.rejects(() => storageService.describe(recording)) + assert.equal((await other.get('/users/me')).status, 401) + assert.equal((await admin.get('/admin/users')).body.data.some((user) => user.id === otherId), false) + assert.equal((await admin.get('/submissions')).body.data.length, 2) + assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 404) + }) + + test('device restrictions reject matching IDs, expose only a hint, and can be removed', async () => { + const deviceId = 'device-test-1234567890' + const deviceLogin = new Client(base) + await deviceLogin.post('/auth/login', { email: 'sara@example.com', password: 'password1' }, { headers: { 'x-device-id': deviceId } }) + const sara = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'sara@example.com') + assert.equal(sara.lastDeviceId, deviceId) + assert.equal((await deviceLogin.get('/auth/session')).body.data.lastDeviceId, undefined) + const made = await admin.post('/admin/restrictions', { type: 'device', value: deviceId, reason: 'Abuse' }) + assert.equal(made.status, 201) + assert.equal(made.body.data.identifierHint, 'device-t...7890') + assert.equal(made.body.data.identifierHash, undefined) + const blocked = new Client(base) + assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 403) + const duplicate = await admin.post('/admin/restrictions', { type: 'device', value: deviceId }) + assert.equal(duplicate.status, 409) + assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) + assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 200) + }) + + test('IP restrictions block API traffic while admin routes remain available for recovery', async () => { + assert.equal((await admin.post('/admin/restrictions', { type: 'ip', value: 'not-an-ip' })).status, 422) + const made = await admin.post('/admin/restrictions', { type: 'ip', value: '::ffff:127.0.0.1' }) + assert.equal(made.status, 201) + assert.equal((await new Client(base).get('/videos')).status, 403) + assert.equal((await admin.get('/admin/restrictions')).status, 200) + assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) + assert.equal((await new Client(base).get('/videos')).status, 200) + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66db6b9..1411a79 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,9 @@ "globals": "^17.12.0", "react-snap": "^1.23.0", "vite": "^8.3.0" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/@babel/code-frame": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe4096b..9bd651f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import { paths } from './routes/appRoutes' import AdminCategories from './pages/admin/AdminCategories' import AdminDashboard from './pages/admin/AdminDashboard' import AdminLayout from './pages/admin/AdminLayout' +import AdminUsers from './pages/admin/AdminUsers' import AdminVideos from './pages/admin/AdminVideos' import CategoryBrowser from './pages/CategoryBrowser' import Demo from './pages/Demo' @@ -42,6 +43,7 @@ export default function App() { }> }> } /> + } /> } /> } /> diff --git a/frontend/src/pages/admin/AdminLayout.jsx b/frontend/src/pages/admin/AdminLayout.jsx index c2dfc3d..c43d7a9 100644 --- a/frontend/src/pages/admin/AdminLayout.jsx +++ b/frontend/src/pages/admin/AdminLayout.jsx @@ -4,7 +4,7 @@ import { paths } from '../../routes/appRoutes' import { useAuth } from '../../context/AuthContext' import { greeting } from '../../utils/format' -const tabs = [['/admin', 'Dashboard', true], ['/admin/videos', 'Videos', false], ['/admin/categories', 'Categories', false]] +const tabs = [['/admin', 'Dashboard', true], ['/admin/users', 'Users', false], ['/admin/videos', 'Videos', false], ['/admin/categories', 'Categories', false]] export default function AdminLayout() { const { user } = useAuth() diff --git a/frontend/src/pages/admin/AdminUsers.jsx b/frontend/src/pages/admin/AdminUsers.jsx new file mode 100644 index 0000000..036616a --- /dev/null +++ b/frontend/src/pages/admin/AdminUsers.jsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import Field from '../../components/Field' +import { Alert, Badge, Button, SectionHeading } from '../../components/ui' +import { useAuth } from '../../context/AuthContext' +import { api } from '../../services/api' +import { formatDate } from '../../utils/format' + +const emptyRestriction = { type: 'ip', value: '', reason: '' } + +export default function AdminUsers() { + const { user: currentUser } = useAuth() + const [users, setUsers] = useState([]) + const [restrictions, setRestrictions] = useState([]) + const [form, setForm] = useState(emptyRestriction) + const [loading, setLoading] = useState(true) + const [busyId, setBusyId] = useState('') + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + + useEffect(() => { + let active = true + Promise.all([api.users.list(), api.admin.restrictions()]) + .then(([userList, restrictionList]) => { + if (active) { + setUsers(userList) + setRestrictions(restrictionList) + } + }) + .catch((cause) => { if (active) setError(cause.message || 'Unable to load access controls.') }) + .finally(() => { if (active) setLoading(false) }) + return () => { active = false } + }, []) + + async function changeStatus(target) { + const status = target.status === 'suspended' ? 'active' : 'suspended' + setBusyId(target.id) + setError('') + setNotice('') + try { + const updated = await api.users.setStatus(target.id, { status }) + setUsers((items) => items.map((item) => item.id === updated.id ? updated : item)) + setNotice(`${target.firstName} ${target.surname} is ${status}.`) + } catch (cause) { setError(cause.message || 'Unable to update this account.') } + finally { setBusyId('') } + } + + async function deleteUser(target) { + if (!window.confirm(`Delete ${target.firstName} ${target.surname} and their submitted recordings? This cannot be undone.`)) return + setBusyId(target.id) + setError('') + setNotice('') + try { + await api.users.remove(target.id) + setUsers((items) => items.filter((item) => item.id !== target.id)) + setNotice(`${target.firstName} ${target.surname} was deleted.`) + } catch (cause) { setError(cause.message || 'Unable to delete this account.') } + finally { setBusyId('') } + } + + async function addRestriction(event) { + event.preventDefault() + setError('') + setNotice('') + try { + const created = await api.admin.createRestriction(form) + setRestrictions((items) => [created, ...items]) + setForm(emptyRestriction) + setNotice(`${created.type === 'ip' ? 'IP address' : 'Device'} restricted.`) + } catch (cause) { setError(cause.message || 'Unable to create this restriction.') } + } + + async function removeRestriction(item) { + setBusyId(item.id) + setError('') + setNotice('') + try { + await api.admin.removeRestriction(item.id) + setRestrictions((items) => items.filter((restriction) => restriction.id !== item.id)) + setNotice('Restriction removed.') + } catch (cause) { setError(cause.message || 'Unable to remove this restriction.') } + finally { setBusyId('') } + } + + return <> + {error && {error}} + {notice && {notice}} + +
+ {users.length} accounts} /> + {loading ?

Loading accounts...

: users.length ?
+ + + {users.map((item) => + + + + + + + + )} +
NameEmailRoleStatusLast deviceJoinedActions
{item.firstName} {item.surname}{item.email}{item.role}{item.status || 'active'}{item.statusReason && {item.statusReason}}{item.lastDeviceId ? {item.lastDeviceId.slice(0, 8)}...{item.lastDeviceId.slice(-4)} : 'Not seen'}{formatDate(item.createdAt)}
+ {item.lastDeviceId && } + + +
+
:

No accounts found.

} +
+ +
+
+ +
+ setForm((values) => ({ ...values, type: event.target.value, value: '' }))}> + + + setForm((values) => ({ ...values, value: event.target.value }))} required maxLength={128} autoComplete="off" /> + setForm((values) => ({ ...values, reason: event.target.value }))} maxLength={500} /> + + +
+ +
+ {restrictions.length} active} /> + {restrictions.length ?
    {restrictions.map((item) =>
  • +
    {item.type}{item.identifierHint}{item.reason || 'No reason provided'} ยท {formatDate(item.createdAt)}
    + +
  • )}
:

No active restrictions.

} +
+
+ +} \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index fed8ad3..70becb6 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -10,6 +10,22 @@ const readLocal = (key, fallback) => { } const writeLocal = (key, value) => window.localStorage.setItem(PREFIX + key, JSON.stringify(value)) +function getDeviceId() { + try { + const key = `${PREFIX}device-id` + let id = window.localStorage.getItem(key) + if (!id) { + const bytes = new Uint8Array(16) + window.crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('') + id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + window.localStorage.setItem(key, id) + } + return id + } catch { return '' } +} const asTimestamp = (value) => value ? Date.parse(value) || value : value const apiOrigin = (import.meta.env.VITE_API_ORIGIN || '').replace(/\/$/, '') const normalize = (value) => { @@ -32,6 +48,8 @@ const toBlob = async (value) => { async function request(path, options = {}) { const headers = new Headers(options.headers) + const deviceId = getDeviceId() + if (deviceId) headers.set('X-Device-ID', deviceId) const isForm = options.body instanceof FormData if (!isForm && options.body !== undefined) headers.set('Content-Type', 'application/json') const response = await fetch(path, { ...options, headers, credentials: 'include' }) @@ -75,6 +93,14 @@ export const api = { users: { async list() { return normalizeList(await request(apiRoutes.users.list())) }, + async setStatus(id, patch) { return normalize(await request(apiRoutes.users.update(id), json('PATCH', patch))) }, + async remove(id) { await request(apiRoutes.users.remove(id), { method: 'DELETE' }) }, + }, + + admin: { + async restrictions() { return normalizeList(await request(apiRoutes.admin.restrictions())) }, + async createRestriction(values) { return normalize(await request(apiRoutes.admin.createRestriction(), json('POST', values))) }, + async removeRestriction(id) { await request(apiRoutes.admin.removeRestriction(id), { method: 'DELETE' }) }, }, categories: { diff --git a/frontend/src/services/apiRoutes.js b/frontend/src/services/apiRoutes.js index 74db623..8946555 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -20,6 +20,8 @@ export const apiRoutes = { users: { me: () => `${API_BASE}/users/me`, list: () => `${API_BASE}/admin/users`, + update: (id) => `${API_BASE}/admin/users/${id}`, + remove: (id) => `${API_BASE}/admin/users/${id}`, }, categories: { @@ -51,6 +53,9 @@ export const apiRoutes = { admin: { users: () => `${API_BASE}/admin/users`, + restrictions: () => `${API_BASE}/admin/restrictions`, + createRestriction: () => `${API_BASE}/admin/restrictions`, + removeRestriction: (id) => `${API_BASE}/admin/restrictions/${id}`, stats: (days) => `${API_BASE}/admin/stats${qs({ days })}`, messages: (limit) => `${API_BASE}/admin/messages${qs({ limit })}`, }, diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 74305ef..5b0949e 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -10,6 +10,19 @@ .admin-body .panel + .panel, .admin-split .panel + .panel { @apply mt-0; } .admin-toolbar { @apply flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; } .admin-alert { @apply mb-4; } + .admin-access-grid { @apply lg:grid-cols-2; } + .admin-user-table { min-width: 58rem; } + .admin-row-actions { @apply flex flex-wrap gap-2; } + .admin-row-actions .btn, .restriction-list .btn { @apply min-h-9 px-3 py-1.5 text-xs; } + .admin-row-actions .is-danger { @apply text-danger; } + .admin-status-reason { @apply mt-1 block max-w-52 text-xs text-muted; } + .admin-restriction-form { @apply grid gap-x-4 sm:grid-cols-2; } + .admin-restriction-form .btn { @apply mt-13 self-start; } + .restriction-list { @apply m-0 grid list-none gap-0 p-0; } + .restriction-list li { @apply flex items-center justify-between gap-4 border-t border-line py-4; } + .restriction-list li > div { @apply grid min-w-0 grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1; } + .restriction-list li b { @apply truncate font-ui text-sm; } + .restriction-list li small { @apply col-span-2 text-xs text-muted; } .admin-filters { @apply mb-6 grid grid-cols-1 gap-4 md:grid-cols-3; } .admin-filters .field { @apply mb-0; } .admin-filters .field-control { @apply min-h-12; }