Add 21 files, update 16 files, rename 1 file
This commit is contained in:
1 parent
b9e4ed4dfc
commit
278293b397
39 files changed
+2434
-61
No files matched your search
@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
needsPasswordRehash,
|
||||
createSession,
|
||||
getSession,
|
||||
clearSession,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
getSessionCookieOptions,
|
||||
} from '@/lib/auth';
|
||||
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
|
||||
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -26,11 +28,69 @@ const registerSchema = z.object({
|
||||
schoolId: z.string().optional(),
|
||||
});
|
||||
|
||||
const requestPasswordResetSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
token: z.string().min(40),
|
||||
password: z.string().min(12),
|
||||
});
|
||||
|
||||
export const POST = withRateLimit(async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'request-password-reset') {
|
||||
const parsed = requestPasswordResetSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
|
||||
|
||||
const key = `password-reset:${getRateLimitKey(request)}`;
|
||||
const result = checkRateLimit(key, 5, 60 * 60 * 1000);
|
||||
if (!result.allowed) {
|
||||
const response = createRateLimitResponse(result);
|
||||
return NextResponse.json(response.body, { status: response.status, headers: response.headers });
|
||||
}
|
||||
|
||||
const user = await db.user.findFirst({ where: { email: parsed.data.email, deletedAt: null } });
|
||||
let developmentToken: string | undefined;
|
||||
if (user) {
|
||||
await db.passwordResetToken.deleteMany({ where: { userId: user.id, usedAt: null } });
|
||||
const { token, tokenHash } = createPasswordResetToken();
|
||||
await db.passwordResetToken.create({
|
||||
data: { userId: user.id, tokenHash, expiresAt: passwordResetExpiry() },
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production' || process.env.AUTH_SHOW_RESET_TOKEN === 'true') {
|
||||
developmentToken = token;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'If the account exists, reset instructions have been prepared.',
|
||||
...(developmentToken ? { developmentToken } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'reset-password') {
|
||||
const parsed = resetPasswordSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
|
||||
|
||||
const reset = await db.passwordResetToken.findFirst({
|
||||
where: { tokenHash: hashResetToken(parsed.data.token), usedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!reset || reset.user.deletedAt) {
|
||||
return NextResponse.json({ error: 'Invalid or expired reset token' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.$transaction([
|
||||
db.user.update({ where: { id: reset.userId }, data: { passwordHash: await hashPassword(parsed.data.password) } }),
|
||||
db.passwordResetToken.update({ where: { id: reset.id }, data: { usedAt: new Date() } }),
|
||||
]);
|
||||
return NextResponse.json({ message: 'Password reset complete' });
|
||||
}
|
||||
|
||||
// ── Login ──
|
||||
if (action === 'login') {
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
@@ -62,6 +122,13 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (needsPasswordRehash(user.passwordHash)) {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: await hashPassword(password) },
|
||||
});
|
||||
}
|
||||
|
||||
const secure = isSecureRequest(request);
|
||||
await createSession(user.id, secure);
|
||||
|
||||
@@ -112,6 +179,7 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
lastName,
|
||||
schoolId: schoolId ?? null,
|
||||
role: 'TEACHER',
|
||||
emailVerifiedAt: process.env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true' ? null : new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
import { deleteStorageFile, readStorageFile, writeStorageFile } from '@/lib/storage';
|
||||
|
||||
// GET /api/backup — List backups for a school
|
||||
export async function GET(req: Request) {
|
||||
@@ -41,9 +42,12 @@ export const POST = withRateLimit(async function POST(req: Request) {
|
||||
if (!backup || backup.status !== 'completed') {
|
||||
return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 });
|
||||
}
|
||||
// In a real implementation, we would restore the DB from the backup JSON
|
||||
// For now, we just mark the restore as acknowledged
|
||||
return NextResponse.json({ message: 'Backup restore initiated', backup });
|
||||
const content = await readStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
|
||||
return NextResponse.json({
|
||||
message: 'Backup file verified. Restore must run in a disposable maintenance environment.',
|
||||
backup,
|
||||
bytes: content.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new backup — export entire DB as JSON
|
||||
@@ -106,6 +110,7 @@ export const POST = withRateLimit(async function POST(req: Request) {
|
||||
|
||||
const jsonStr = JSON.stringify(backupData, null, 2);
|
||||
const size = Buffer.byteLength(jsonStr, 'utf-8');
|
||||
await writeStorageFile('backups', `${schoolId}/${filename}`, jsonStr);
|
||||
|
||||
const backup = await db.backup.create({
|
||||
data: {
|
||||
@@ -120,7 +125,6 @@ export const POST = withRateLimit(async function POST(req: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
...backup,
|
||||
data: backupData, // Include the actual backup data for download
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Backup create error:', error);
|
||||
@@ -151,6 +155,11 @@ export async function DELETE(req: NextRequest) {
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||
}
|
||||
const backup = await db.backup.findUnique({ where: { id } });
|
||||
if (!backup) {
|
||||
return NextResponse.json({ error: 'Backup not found' }, { status: 404 });
|
||||
}
|
||||
await deleteStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
|
||||
await db.backup.delete({ where: { id } });
|
||||
return NextResponse.json({ message: 'Backup deleted' });
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user