Files
ReadMeABook/src/app/api/auth/change-password/route.ts
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

188 lines
5.1 KiB
TypeScript

/**
* Component: User Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Auth.ChangePassword');
/**
* POST /api/auth/change-password
* Change password for any authenticated local user
*
* Security:
* - Only available to local users (authProvider='local')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!allowWeakPassword && newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
authToken: true,
authProvider: true,
plexId: true,
plexUsername: true,
},
});
if (!user) {
return NextResponse.json(
{
success: false,
error: 'User not found',
},
{ status: 404 }
);
}
// Check if user is a local user (can change password)
if (user.authProvider !== 'local') {
return NextResponse.json(
{
success: false,
error: 'Password change is only available for local users. Your account is managed by an external provider.',
},
{ status: 403 }
);
}
if (!user.authToken) {
return NextResponse.json(
{
success: false,
error: 'Invalid account configuration',
},
{ status: 400 }
);
}
// Decrypt the stored hash before comparing
const encryptionService = getEncryptionService();
let decryptedHash: string;
try {
decryptedHash = encryptionService.decrypt(user.authToken);
} catch (error) {
logger.error('Failed to decrypt password hash', {
userId: user.id,
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{
success: false,
error: 'Failed to verify current password',
},
{ status: 500 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, decryptedHash);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Encrypt the new hash before storing
const encryptedHash = encryptionService.encrypt(hashedPassword);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: encryptedHash,
updatedAt: new Date(),
},
});
logger.info('Password changed successfully', {
userId: user.id,
username: user.plexUsername
});
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
logger.error('Failed to change password', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
}