Skip to content

Commit 0851e75

Browse files
committed
feat(auth): require admin approval for teacher accounts
New teacher registrations are created pending admin approval and are not auto-logged-in. Login and every authenticated request are refused for unapproved teachers (force-logging out any existing sessions), and all teacher action routes are gated. Adds an admin API and an Approvals page (list/approve/reject) guarded by an isAdmin flag or the SPANDAN_ADMIN_EMAILS allowlist, plus a migration that sets existing teachers pending and promotes the founder account.
1 parent 1126ce5 commit 0851e75

16 files changed

Lines changed: 480 additions & 14 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* One-time migration for the teacher-approval feature.
3+
*
4+
* Approach for existing accounts: the founder account(s) are set approved + admin so they
5+
* can sign in and reach the admin page; EVERY other existing teacher is set to 'pending' so
6+
* they surface on the admin approval page for manual approve/reject (this also lets the admin
7+
* flow be tested against real accounts, and the junk/attack accounts get rejected there).
8+
*
9+
* The field must be PERSISTED (not left to the Mongoose default) so that the admin page's
10+
* `find({ teacherApprovalStatus: 'pending' })` query actually returns these old accounts.
11+
*
12+
* SAFE BY DEFAULT: dry-run (prints the plan, writes nothing). Pass --apply to write.
13+
*
14+
* node backend/scripts/migrate_teacher_approval.js # dry-run
15+
* node backend/scripts/migrate_teacher_approval.js --apply # perform updates
16+
*
17+
* Uses MONGODB_URI (falls back to mongodb://127.0.0.1:27017/spandan). Run OFF the live
18+
* session, and review the printed plan before using --apply.
19+
*/
20+
import mongoose from 'mongoose'
21+
import User from '../src/models/User.js'
22+
23+
const APPLY = process.argv.includes('--apply')
24+
const URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/spandan'
25+
26+
// Founder account(s): set approved + admin so they can sign in and reach the admin page.
27+
// Override with FOUNDER_ADMIN_EMAILS (comma-separated). EVERY other existing teacher is set
28+
// to 'pending' so it appears on the admin approval page for manual approve/reject.
29+
const FOUNDER_ADMINS = (process.env.FOUNDER_ADMIN_EMAILS || 'imrohitvk@gmail.com')
30+
.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
31+
32+
const isFounder = (email = '') => FOUNDER_ADMINS.includes(email.toLowerCase())
33+
34+
async function main() {
35+
await mongoose.connect(URI)
36+
console.log(`Connected: ${URI} | mode: ${APPLY ? 'APPLY (writing)' : 'DRY-RUN (no writes)'}`)
37+
38+
const teachers = await User.find({ role: 'teacher' }).select('name email teacherApprovalStatus isActive isAdmin')
39+
const founders = [], pending = []
40+
for (const t of teachers) {
41+
if (isFounder(t.email)) founders.push(t)
42+
else pending.push(t)
43+
}
44+
45+
console.log(`\nTeachers: ${teachers.length} -> founder(approved+admin) ${founders.length}, set-pending ${pending.length}`)
46+
console.log('\n-- FOUNDER -> approved + isAdmin=true (can sign in and administer) --')
47+
founders.forEach(t => console.log(` ${t.email} (${t.name})`))
48+
console.log('\n-- SET PENDING -> will appear on the admin page for approve/reject --')
49+
pending.forEach(t => console.log(` ${t.email} (${t.name})`))
50+
51+
if (!APPLY) {
52+
console.log('\nDRY-RUN complete. Re-run with --apply to write these changes.')
53+
await mongoose.disconnect(); return
54+
}
55+
56+
const founderIds = founders.map(t => t._id)
57+
const pendingIds = pending.map(t => t._id)
58+
// Approve+admin the founders FIRST so the admin is never locked out.
59+
const r1 = founderIds.length ? await User.updateMany({ _id: { $in: founderIds } }, { $set: { teacherApprovalStatus: 'approved', isAdmin: true } }) : { modifiedCount: 0 }
60+
const r2 = pendingIds.length ? await User.updateMany({ _id: { $in: pendingIds } }, { $set: { teacherApprovalStatus: 'pending' } }) : { modifiedCount: 0 }
61+
console.log(`\nApplied: founders(approved+admin)=${r1.modifiedCount}, set-pending=${r2.modifiedCount}`)
62+
await mongoose.disconnect()
63+
}
64+
65+
main().catch(e => { console.error(e); process.exit(1) })

backend/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import transcriptionRoutes from './routes/transcription.js'
2121
import transcriptRoutes from './routes/transcripts.js'
2222
import responseRoutes from './routes/responses.js'
2323
import researchRoutes from './routes/research.js'
24+
import adminRoutes from './routes/admin.js'
2425

2526
// Import models for reference
2627
import './models/index.js'
@@ -357,6 +358,7 @@ app.use('/api/transcription', transcriptionRoutes)
357358
app.use('/api/transcripts', transcriptRoutes)
358359
app.use('/api/responses', responseRoutes)
359360
app.use('/api/research', researchRoutes)
361+
app.use('/api/admin', adminRoutes)
360362

361363
// Health check
362364
app.get('/api/health', (req, res) => {

backend/src/middleware/auth.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ export const authenticate = async (req, res, next) => {
5454
})
5555
}
5656

57+
// Force-logout unapproved teachers. A teacher whose account is pending or rejected may still
58+
// hold a valid token — issued before approval was required, or before an admin revoked them.
59+
// Returning 401 makes the client's global fetch interceptor drop the session and send them to
60+
// the login screen (which then explains they are awaiting approval). Login separately blocks
61+
// them, so they cannot get back in until approved. Approve/reject clears the auth cache, so
62+
// this takes effect on their very next request rather than after the cache TTL.
63+
if (user.role === 'teacher' && user.teacherApprovalStatus !== 'approved') {
64+
return res.status(401).json({
65+
error: 'Approval required',
66+
code: 'TEACHER_NOT_APPROVED',
67+
message: 'Your teacher account is awaiting admin approval. Please sign in once it is approved.'
68+
})
69+
}
70+
5771
req.user = user
5872
next()
5973
} catch (error) {
@@ -93,6 +107,39 @@ export const authorize = (...roles) => {
93107
}
94108
}
95109

110+
// Defense-in-depth gate for teacher-only actions. The primary control is that an
111+
// unapproved teacher is never issued a JWT (blocked at /register/verify and /login),
112+
// so this normally never fires; it covers the edge cases where a token already exists
113+
// (approval revoked mid-session, or a student self-promoted to teacher via PUT /role).
114+
// Place AFTER authenticate + authorize('teacher') on every teacher write/session route.
115+
export const requireApprovedTeacher = (req, res, next) => {
116+
if (req.user && req.user.role === 'teacher' && req.user.teacherApprovalStatus !== 'approved') {
117+
return res.status(403).json({
118+
error: 'Approval pending',
119+
code: 'TEACHER_NOT_APPROVED',
120+
message: 'Your teacher account is awaiting admin approval.'
121+
})
122+
}
123+
next()
124+
}
125+
126+
// Admins are identified by the persisted isAdmin flag OR a bootstrap allowlist from the
127+
// SPANDAN_ADMIN_EMAILS env var (comma-separated), so the first admin can act before any
128+
// isAdmin flag is set in the DB. Never trust a client-supplied admin claim.
129+
const ADMIN_EMAILS = (process.env.SPANDAN_ADMIN_EMAILS || '')
130+
.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
131+
132+
export const authorizeAdmin = (req, res, next) => {
133+
if (!req.user) {
134+
return res.status(401).json({ error: 'Not authenticated', message: 'Please sign in.' })
135+
}
136+
const isAdmin = req.user.isAdmin === true || ADMIN_EMAILS.includes((req.user.email || '').toLowerCase())
137+
if (!isAdmin) {
138+
return res.status(403).json({ error: 'Access denied', message: 'Admin access required.' })
139+
}
140+
next()
141+
}
142+
96143
export const generateToken = (userId) => {
97144
return jwt.sign(
98145
{ userId },

backend/src/models/User.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,34 @@ const userSchema = new mongoose.Schema({
3333
enum: ['teacher', 'student'],
3434
required: [true, 'Role is required']
3535
},
36+
// Teacher accounts must be approved by an admin before they can sign in or use any
37+
// teacher functionality. Students are 'approved' by default (the field is only
38+
// consulted when role === 'teacher'). New teacher registrations start 'pending'.
39+
teacherApprovalStatus: {
40+
type: String,
41+
enum: ['pending', 'approved', 'rejected'],
42+
default: 'pending'
43+
},
44+
// Grants access to the admin approval page and endpoints. Set only via the migration
45+
// script or another admin; never client-settable.
46+
isAdmin: {
47+
type: Boolean,
48+
default: false
49+
},
50+
approvedBy: {
51+
type: mongoose.Schema.Types.ObjectId,
52+
ref: 'User',
53+
default: null
54+
},
55+
approvedAt: {
56+
type: Date,
57+
default: null
58+
},
59+
rejectionReason: {
60+
type: String,
61+
default: '',
62+
maxlength: [500, 'Rejection reason cannot exceed 500 characters']
63+
},
3664
profileImage: {
3765
type: String,
3866
default: ''

backend/src/routes/admin.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import express from 'express'
2+
import User from '../models/User.js'
3+
import { authenticate, authorizeAdmin, clearUserCache } from '../middleware/auth.js'
4+
5+
const router = express.Router()
6+
7+
// Every admin route requires a signed-in admin (isAdmin flag OR SPANDAN_ADMIN_EMAILS allowlist).
8+
router.use(authenticate, authorizeAdmin)
9+
10+
const STATUSES = ['pending', 'approved', 'rejected']
11+
const SAFE_FIELDS = 'name email teacherApprovalStatus isActive createdAt approvedAt approvedBy rejectionReason'
12+
13+
// List teacher accounts by approval status (default: pending). Used by the admin page.
14+
router.get('/teacher-requests', async (req, res) => {
15+
try {
16+
const status = req.query.status
17+
const filter = { role: 'teacher' }
18+
if (STATUSES.includes(status)) filter.teacherApprovalStatus = status
19+
else if (status && status !== 'all') return res.status(400).json({ error: 'Invalid status filter' })
20+
21+
const requests = await User.find(filter).select(SAFE_FIELDS).sort({ createdAt: -1 }).lean()
22+
const counts = {}
23+
for (const s of STATUSES) counts[s] = await User.countDocuments({ role: 'teacher', teacherApprovalStatus: s })
24+
res.json({ requests, counts })
25+
} catch (error) {
26+
res.status(500).json({ error: error.message })
27+
}
28+
})
29+
30+
// Approve a pending teacher: they can now sign in and use teacher features.
31+
router.post('/teacher-requests/:id/approve', async (req, res) => {
32+
try {
33+
const user = await User.findById(req.params.id)
34+
if (!user || user.role !== 'teacher') return res.status(404).json({ error: 'Teacher account not found' })
35+
36+
user.teacherApprovalStatus = 'approved'
37+
user.approvedBy = req.user._id
38+
user.approvedAt = new Date()
39+
user.rejectionReason = ''
40+
user.isActive = true
41+
await user.save()
42+
clearUserCache() // reflect immediately, not after the auth-cache TTL
43+
44+
res.json({ message: 'Teacher approved', user: user.toJSON() })
45+
} catch (error) {
46+
res.status(500).json({ error: error.message })
47+
}
48+
})
49+
50+
// Reject a teacher request (optionally with a reason). They stay unable to sign in.
51+
router.post('/teacher-requests/:id/reject', async (req, res) => {
52+
try {
53+
const user = await User.findById(req.params.id)
54+
if (!user || user.role !== 'teacher') return res.status(404).json({ error: 'Teacher account not found' })
55+
56+
user.teacherApprovalStatus = 'rejected'
57+
user.rejectionReason = (req.body?.reason || '').toString().slice(0, 500)
58+
user.approvedBy = req.user._id
59+
user.approvedAt = new Date()
60+
await user.save()
61+
clearUserCache()
62+
63+
res.json({ message: 'Teacher request rejected', user: user.toJSON() })
64+
} catch (error) {
65+
res.status(500).json({ error: error.message })
66+
}
67+
})
68+
69+
export default router

backend/src/routes/auth.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ router.post('/register/verify', validate(verifyRegistrationSchema), async (req,
4040
const { name, email, password, role, otp } = req.validatedBody
4141
await verifyRegistrationOtp(email, otp) // throws on invalid/expired/too-many-attempts
4242
const user = await register(name, email, password, role) // creates the (now email-verified) account
43+
44+
// Teacher accounts are NOT auto-logged-in: they require admin approval first. We issue
45+
// no token and return a pendingApproval flag so the client sends the registrant back to
46+
// the login screen with an "admin approval pending" message. Students log in immediately.
47+
if (user.role === 'teacher') {
48+
return res.status(202).json({
49+
pendingApproval: true,
50+
message: 'Registration successful. Your teacher account is pending admin approval. You will be able to sign in once an administrator approves it.'
51+
})
52+
}
53+
4354
const token = generateToken(user._id)
4455
res.status(201).json({
4556
message: 'Registration successful',
@@ -60,6 +71,16 @@ router.post('/login', validate(loginSchema), async (req, res) => {
6071
try {
6172
const { email, password } = req.validatedBody
6273
const user = await login(email, password)
74+
75+
// A teacher who is not yet approved (or was rejected) is refused a token and bounced back
76+
// to the login screen with a clear message, rather than landing in the teacher dashboard.
77+
if (user.role === 'teacher' && user.teacherApprovalStatus !== 'approved') {
78+
const message = user.teacherApprovalStatus === 'rejected'
79+
? 'Your teacher account request was not approved. Please contact the administrator.'
80+
: 'Your teacher account is awaiting admin approval. Please try signing in again once it is approved.'
81+
return res.status(403).json({ error: message, code: 'TEACHER_NOT_APPROVED', status: user.teacherApprovalStatus })
82+
}
83+
6384
const token = generateToken(user._id)
6485

6586
res.json({

backend/src/routes/questions.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import express from 'express'
2-
import { authenticate, authorize } from '../middleware/auth.js'
2+
import { authenticate, authorize, requireApprovedTeacher } from '../middleware/auth.js'
33
import { generateQuestions, AI_PROVIDERS } from '../services/questionService.js'
44
import { getGenerationQueue } from '../services/generationQueue.js'
55
import { stripObject } from '../utils/sanitize.js'
@@ -27,7 +27,7 @@ router.get('/providers', (req, res) => {
2727

2828
// POST /api/questions/generate - Generate questions from transcript
2929
// Authorization: teacher only
30-
router.post('/generate', authorize('teacher'), async (req, res) => {
30+
router.post('/generate', authorize('teacher'), requireApprovedTeacher, async (req, res) => {
3131
try {
3232
const { transcript, config } = req.body
3333
const {
@@ -79,7 +79,7 @@ router.post('/generate', authorize('teacher'), async (req, res) => {
7979

8080
// GET /api/questions/jobs/:jobId - poll an async generation job (Phase 2D)
8181
// Authorization: teacher only, and only the teacher who requested it.
82-
router.get('/jobs/:jobId', authorize('teacher'), async (req, res) => {
82+
router.get('/jobs/:jobId', authorize('teacher'), requireApprovedTeacher, async (req, res) => {
8383
try {
8484
const queue = getGenerationQueue()
8585
if (!queue) {
@@ -108,7 +108,7 @@ router.get('/jobs/:jobId', authorize('teacher'), async (req, res) => {
108108

109109
// Create a question (for manual creation)
110110
// Authorization: teacher only
111-
router.post('/', authorize('teacher'), async (req, res) => {
111+
router.post('/', authorize('teacher'), requireApprovedTeacher, async (req, res) => {
112112
try {
113113
const Question = (await import('../models/Question.js')).default
114114
const {

backend/src/routes/rooms.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import express from 'express'
22
import { createRoom, getRoomById, getRoomByCode, getRoomsByTeacher, getRoomsByStudent, getActiveRoomsByStudent, updateRoom, deleteRoom } from '../services/roomService.js'
33
import { authenticate } from '../middleware/auth.js'
4-
import { authorize } from '../middleware/auth.js'
4+
import { authorize, requireApprovedTeacher } from '../middleware/auth.js'
55
import { validate, createRoomSchema } from '../middleware/validation.js'
66
import { rebuildSnapshot } from '../services/resultsSnapshot.js'
77

88
const router = express.Router()
99

1010
// Create new room
11-
router.post('/', authenticate, authorize('teacher'), validate(createRoomSchema), async (req, res) => {
11+
router.post('/', authenticate, authorize('teacher'), requireApprovedTeacher, validate(createRoomSchema), async (req, res) => {
1212
try {
1313
const { name, settings } = req.validatedBody
1414
const room = await createRoom(name, req.user._id, settings)
@@ -127,7 +127,7 @@ router.get('/student/active', authenticate, authorize('student'), async (req, re
127127
})
128128

129129
// Update room
130-
router.put('/:id', authenticate, authorize('teacher'), async (req, res) => {
130+
router.put('/:id', authenticate, authorize('teacher'), requireApprovedTeacher, async (req, res) => {
131131
try {
132132
const room = await getRoomById(req.params.id)
133133

@@ -163,7 +163,7 @@ router.put('/:id', authenticate, authorize('teacher'), async (req, res) => {
163163
})
164164

165165
// Delete room
166-
router.delete('/:id', authenticate, authorize('teacher'), async (req, res) => {
166+
router.delete('/:id', authenticate, authorize('teacher'), requireApprovedTeacher, async (req, res) => {
167167
try {
168168
const room = await getRoomById(req.params.id)
169169

backend/src/routes/transcription.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import express from 'express'
2-
import { authenticate, authorize } from '../middleware/auth.js'
2+
import { authenticate, authorize, requireApprovedTeacher } from '../middleware/auth.js'
33

44
const router = express.Router()
55

@@ -23,7 +23,7 @@ router.get('/status', authenticate, async (req, res) => {
2323
})
2424

2525
// Transcribe an audio chunk — forwarded to the faster-whisper service
26-
router.post('/transcribe', authenticate, authorize('teacher'), async (req, res) => {
26+
router.post('/transcribe', authenticate, authorize('teacher'), requireApprovedTeacher, async (req, res) => {
2727
if (!req.body || !req.body.audio) {
2828
return res.status(400).json({ error: 'No audio provided' })
2929
}

0 commit comments

Comments
 (0)