Add 21 files, update 16 files, rename 1 file

This commit is contained in:
[dyad] committed 2026-08-02 17:26:49 +02:00
1 parent b9e4ed4dfc
commit 278293b397
39 files changed
+2434 -61

No files matched your search

+68
View File
@@ -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(),
},
});
+13 -4
View File
@@ -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) {