first commit
This commit is contained in:
60
app/api/admin/emails/preview/route.ts
Normal file
60
app/api/admin/emails/preview/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { renderEmailTemplate } from '@/lib/email-renderer';
|
||||
|
||||
/**
|
||||
* GET /api/admin/emails/preview?template=welcome
|
||||
* Render email template with sample data for preview
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const template = searchParams.get('template');
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sample data for each template
|
||||
const sampleData: Record<string, any> = {
|
||||
welcome: {
|
||||
username: 'John Doe',
|
||||
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
|
||||
temporaryPassword: 'TempPass123!',
|
||||
},
|
||||
'password-reset': {
|
||||
username: 'John Doe',
|
||||
resetUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=sample-token-123`,
|
||||
expiresIn: '1 hour',
|
||||
},
|
||||
};
|
||||
|
||||
if (!sampleData[template]) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown template: ${template}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const html = await renderEmailTemplate(template, sampleData[template]);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Email preview failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to render email template' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/api/admin/emails/send-test/route.ts
Normal file
106
app/api/admin/emails/send-test/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
// Simple rate limiting (in-memory)
|
||||
const rateLimitMap = new Map<string, number[]>();
|
||||
const RATE_LIMIT = 5; // max requests
|
||||
const RATE_WINDOW = 60 * 1000; // per minute
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const requests = rateLimitMap.get(ip) || [];
|
||||
|
||||
// Filter out old requests
|
||||
const recentRequests = requests.filter(time => now - time < RATE_WINDOW);
|
||||
|
||||
if (recentRequests.length >= RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
rateLimitMap.set(ip, recentRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/emails/send-test
|
||||
* Send test email with specific template
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please wait a minute.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { template, email } = body;
|
||||
|
||||
if (!template || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template and email are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
|
||||
// Send appropriate template
|
||||
switch (template) {
|
||||
case 'welcome':
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username: 'Test User',
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
temporaryPassword: 'TempPass123!',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'password-reset':
|
||||
await emailService.sendPasswordResetEmail({
|
||||
email,
|
||||
username: 'Test User',
|
||||
resetUrl: `${baseUrl}/reset-password?token=sample-token-123`,
|
||||
expiresIn: '1 hour',
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown template: ${template}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${email}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Send test email failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/api/admin/settings/smtp/route.ts
Normal file
149
app/api/admin/settings/smtp/route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { settingsDb } from '@/lib/settings-db';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { SMTPConfig, SMTPConfigResponse } from '@/lib/types/smtp';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/smtp
|
||||
* Returns current SMTP configuration (password masked)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const dbConfig = settingsDb.getSMTPConfig();
|
||||
|
||||
let response: SMTPConfigResponse;
|
||||
|
||||
if (dbConfig) {
|
||||
// Mask password
|
||||
const maskedConfig = {
|
||||
...dbConfig,
|
||||
auth: {
|
||||
...dbConfig.auth,
|
||||
pass: '***',
|
||||
},
|
||||
};
|
||||
response = { config: maskedConfig, source: 'database' };
|
||||
} else {
|
||||
// Check if env config exists
|
||||
const hasEnvConfig =
|
||||
process.env.SMTP_HOST &&
|
||||
process.env.SMTP_USER &&
|
||||
process.env.SMTP_PASS;
|
||||
|
||||
if (hasEnvConfig) {
|
||||
const envConfig: SMTPConfig = {
|
||||
host: process.env.SMTP_HOST!,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER!,
|
||||
pass: '***',
|
||||
},
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL || '',
|
||||
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
|
||||
},
|
||||
replyTo: process.env.SMTP_REPLY_TO,
|
||||
timeout: 10000,
|
||||
};
|
||||
response = { config: envConfig, source: 'env' };
|
||||
} else {
|
||||
response = { config: null, source: 'env' };
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to get SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/smtp
|
||||
* Save SMTP configuration to database
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const config = body.config as SMTPConfig;
|
||||
|
||||
// Trim whitespace from credentials to prevent auth errors
|
||||
if (config.host) config.host = config.host.trim();
|
||||
if (config.auth?.user) config.auth.user = config.auth.user.trim();
|
||||
if (config.auth?.pass) config.auth.pass = config.auth.pass.trim();
|
||||
if (config.from?.email) config.from.email = config.from.email.trim();
|
||||
|
||||
// Validation
|
||||
if (!config.host || !config.port || !config.auth?.user || !config.auth?.pass) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required SMTP configuration fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (config.port < 1 || config.port > 65535) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Port must be between 1 and 65535' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save to database (password will be encrypted)
|
||||
settingsDb.setSMTPConfig(config);
|
||||
|
||||
// Reset the cached transporter to use new config
|
||||
emailService.resetTransporter();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to save SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/settings/smtp
|
||||
* Reset to environment config
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
settingsDb.delete('smtp_config');
|
||||
|
||||
// Reset the cached transporter to use env config
|
||||
emailService.resetTransporter();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to delete SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/api/admin/settings/smtp/test/route.ts
Normal file
78
app/api/admin/settings/smtp/test/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { SMTPConfig } from '@/lib/types/smtp';
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/smtp/test
|
||||
* Test SMTP configuration by sending a test email
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { config, testEmail } = body as { config?: SMTPConfig; testEmail: string };
|
||||
|
||||
if (!testEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Test email address is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(testEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await emailService.testConnection(config);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'SMTP connection failed. Please check your settings.'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send test email
|
||||
try {
|
||||
await emailService.sendWelcomeEmail({
|
||||
email: testEmail,
|
||||
username: 'Test User',
|
||||
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
|
||||
temporaryPassword: undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${testEmail}`,
|
||||
});
|
||||
} catch (sendError) {
|
||||
console.error('[API] Test email send failed:', sendError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Email send failed: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API] SMTP test failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'SMTP test failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
77
app/api/auth/forgot-password/route.ts
Normal file
77
app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import { passwordResetDb } from '@/lib/password-reset-db';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
/**
|
||||
* POST /api/auth/forgot-password
|
||||
* Request password reset email
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const users = userDb.findAll();
|
||||
const user = users.find(u => u.email?.toLowerCase() === email.toLowerCase());
|
||||
|
||||
// SECURITY: Always return success to prevent user enumeration
|
||||
// Even if user doesn't exist, return success but don't send email
|
||||
if (!user) {
|
||||
console.log('[ForgotPassword] Email not found, but returning success (security)');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a password reset link has been sent.',
|
||||
});
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
const token = passwordResetDb.create(user.id, 1); // 1 hour expiry
|
||||
|
||||
// Send password reset email
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
|
||||
|
||||
try {
|
||||
await emailService.sendPasswordResetEmail({
|
||||
email: user.email!,
|
||||
username: user.username,
|
||||
resetUrl,
|
||||
expiresIn: '1 hour',
|
||||
});
|
||||
|
||||
console.log('[ForgotPassword] Password reset email sent to:', user.email);
|
||||
} catch (emailError) {
|
||||
console.error('[ForgotPassword] Failed to send email:', emailError);
|
||||
// Don't fail the request if email fails - log and continue
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a password reset link has been sent.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ForgotPassword] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/api/auth/register/route.ts
Normal file
121
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Public user registration endpoint
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, email, password } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: username, email, password' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Username validation (at least 3 characters, alphanumeric + underscore)
|
||||
if (username.length < 3) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username must be at least 3 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username can only contain letters, numbers, and underscores' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Password validation (at least 6 characters)
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = userDb.findByUsername(username);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username already taken' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const allUsers = userDb.findAll();
|
||||
const emailExists = allUsers.find(u => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (emailExists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email already registered' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user with VIEWER role (new users get viewer access by default)
|
||||
const user = userDb.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
role: 'VIEWER', // New registrations get VIEWER role
|
||||
});
|
||||
|
||||
console.log('[Register] New user registered:', username);
|
||||
|
||||
// Send welcome email (don't fail registration if email fails)
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username,
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
});
|
||||
console.log('[Register] Welcome email sent to:', email);
|
||||
} catch (emailError) {
|
||||
console.error('[Register] Failed to send welcome email:', emailError);
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Account created successfully',
|
||||
user: safeUser,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Register] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Registration failed. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/api/auth/reset-password/route.ts
Normal file
106
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import { passwordResetDb } from '@/lib/password-reset-db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* POST /api/auth/reset-password
|
||||
* Reset password with token
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token, newPassword } = body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token and new password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if (!passwordResetDb.isValid(token)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get token details
|
||||
const resetToken = passwordResetDb.findByToken(token);
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = userDb.findById(resetToken.user_id);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update user password
|
||||
userDb.update(user.id, { passwordHash });
|
||||
|
||||
// Mark token as used
|
||||
passwordResetDb.markUsed(token);
|
||||
|
||||
console.log('[ResetPassword] Password reset successful for user:', user.username);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Password has been reset successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ResetPassword] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset password' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/reset-password?token=xxx
|
||||
* Validate reset token (for checking if link is still valid)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = passwordResetDb.isValid(token);
|
||||
|
||||
return NextResponse.json({ valid: isValid });
|
||||
} catch (error) {
|
||||
console.error('[ResetPassword] Validation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to validate token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/api/devices/[id]/route.ts
Normal file
129
app/api/devices/[id]/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices/[id] - Get single device
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
device: {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
isActive: device.isActive === 1,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
description: device.description,
|
||||
icon: device.icon,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/devices/[id] - Update device (ADMIN only)
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can update devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, color, description, icon } = body;
|
||||
|
||||
const updated = deviceDb.update(id, {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
icon,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Failed to update device" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ device: updated });
|
||||
} catch (error) {
|
||||
console.error("Error updating device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/devices/[id] - Soft delete device (ADMIN only)
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can delete devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const success = deviceDb.delete(id);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Failed to delete device" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Device deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/devices/public/route.ts
Normal file
44
app/api/devices/public/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb, userDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices/public - Authenticated endpoint for device names and colors
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const role = (session.user as any).role;
|
||||
const username = session.user.name || '';
|
||||
|
||||
// Get list of device IDs the user is allowed to access
|
||||
const allowedDeviceIds = userDb.getAllowedDeviceIds(userId, role, username);
|
||||
|
||||
// Fetch all active devices
|
||||
const allDevices = deviceDb.findAll();
|
||||
|
||||
// Filter to only devices the user can access
|
||||
const userDevices = allDevices.filter(device =>
|
||||
allowedDeviceIds.includes(device.id)
|
||||
);
|
||||
|
||||
// Return only public information (id, name, color)
|
||||
const publicDevices = userDevices.map((device) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ devices: publicDevices });
|
||||
} catch (error) {
|
||||
console.error("Error fetching public devices:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch devices" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/api/devices/route.ts
Normal file
120
app/api/devices/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb, locationDb, userDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices - List all devices (from database)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get devices from database (filtered by user ownership)
|
||||
const userId = (session.user as any).id;
|
||||
let targetUserId = userId;
|
||||
|
||||
// If user is a VIEWER with a parent, show parent's devices instead
|
||||
const currentUser = userDb.findById(userId);
|
||||
if (currentUser && currentUser.role === 'VIEWER' && currentUser.parent_user_id) {
|
||||
targetUserId = currentUser.parent_user_id;
|
||||
console.log(`[Devices] VIEWER ${currentUser.username} accessing parent's devices (parent_id: ${targetUserId})`);
|
||||
}
|
||||
|
||||
const devices = deviceDb.findAll({ userId: targetUserId });
|
||||
|
||||
// Fetch location data from local SQLite cache (24h history)
|
||||
const allLocations = locationDb.findMany({
|
||||
user_id: 0, // MQTT devices only
|
||||
timeRangeHours: 24, // Last 24 hours
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
// Merge devices with latest location data
|
||||
const devicesWithLocation = devices.map((device) => {
|
||||
// Find all locations for this device
|
||||
const deviceLocations = allLocations.filter((loc) => loc.username === device.id);
|
||||
|
||||
// Get latest location (first one, already sorted by timestamp DESC)
|
||||
const latestLocation = deviceLocations[0] || null;
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
isActive: device.isActive === 1,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
description: device.description,
|
||||
icon: device.icon,
|
||||
latestLocation: latestLocation,
|
||||
_count: {
|
||||
locations: deviceLocations.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ devices: devicesWithLocation });
|
||||
} catch (error) {
|
||||
console.error("Error fetching devices:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch devices" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/devices - Create new device (ADMIN only)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can create devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name, color, description, icon } = body;
|
||||
|
||||
// Validation
|
||||
if (!id || !name || !color) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: id, name, color" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device with this ID already exists
|
||||
const existing = deviceDb.findById(id);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Device with this ID already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create device
|
||||
const device = deviceDb.create({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
ownerId: (session.user as any).id,
|
||||
description,
|
||||
icon,
|
||||
});
|
||||
|
||||
return NextResponse.json({ device }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
app/api/locations/cleanup/route.ts
Normal file
73
app/api/locations/cleanup/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { locationDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/cleanup (ADMIN only)
|
||||
*
|
||||
* Delete old location records and optimize database
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* "retentionHours": 168 // 7 days default
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Super admin only (username "admin")
|
||||
const session = await auth();
|
||||
const username = session?.user?.name || '';
|
||||
if (!session?.user || username !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const retentionHours = body.retentionHours || 168; // Default: 7 days
|
||||
|
||||
// Validate retention period
|
||||
if (retentionHours <= 0 || retentionHours > 8760) { // Max 1 year
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid retention period. Must be between 1 and 8760 hours (1 year)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get stats before cleanup
|
||||
const statsBefore = locationDb.getStats();
|
||||
|
||||
// Delete old records
|
||||
const deletedCount = locationDb.deleteOlderThan(retentionHours);
|
||||
|
||||
// Get stats after cleanup
|
||||
const statsAfter = locationDb.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: deletedCount,
|
||||
retentionHours,
|
||||
retentionDays: Math.round(retentionHours / 24),
|
||||
before: {
|
||||
total: statsBefore.total,
|
||||
sizeKB: statsBefore.sizeKB,
|
||||
oldest: statsBefore.oldest,
|
||||
newest: statsBefore.newest,
|
||||
},
|
||||
after: {
|
||||
total: statsAfter.total,
|
||||
sizeKB: statsAfter.sizeKB,
|
||||
oldest: statsAfter.oldest,
|
||||
newest: statsAfter.newest,
|
||||
},
|
||||
freedKB: statsBefore.sizeKB - statsAfter.sizeKB,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to cleanup locations',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/api/locations/ingest/route.ts
Normal file
101
app/api/locations/ingest/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { locationDb, Location } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/ingest
|
||||
*
|
||||
* Endpoint for n8n to push location data to local SQLite cache.
|
||||
* This is called AFTER n8n stores the data in NocoDB.
|
||||
*
|
||||
* Expected payload (single location or array):
|
||||
* {
|
||||
* "latitude": 48.1351,
|
||||
* "longitude": 11.5820,
|
||||
* "timestamp": "2024-01-15T10:30:00Z",
|
||||
* "user_id": 0,
|
||||
* "username": "10",
|
||||
* "marker_label": "Device A",
|
||||
* "battery": 85,
|
||||
* "speed": 2.5,
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Security: Add API key validation in production!
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Support both single location and array of locations
|
||||
const locations = Array.isArray(body) ? body : [body];
|
||||
|
||||
if (locations.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No location data provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Debug logging for speed and battery values
|
||||
console.log('[Ingest Debug] Received locations:', locations.map(loc => ({
|
||||
username: loc.username,
|
||||
speed: loc.speed,
|
||||
speed_type: typeof loc.speed,
|
||||
battery: loc.battery,
|
||||
battery_type: typeof loc.battery
|
||||
})));
|
||||
|
||||
// Validate required fields
|
||||
for (const loc of locations) {
|
||||
if (!loc.latitude || !loc.longitude || !loc.timestamp) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: latitude, longitude, timestamp' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into SQLite
|
||||
let insertedCount = 0;
|
||||
if (locations.length === 1) {
|
||||
locationDb.create(locations[0] as Location);
|
||||
insertedCount = 1;
|
||||
} else {
|
||||
insertedCount = locationDb.createMany(locations as Location[]);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
inserted: insertedCount,
|
||||
message: `Successfully stored ${insertedCount} location(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Location ingest error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to store location data',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/locations/ingest/stats
|
||||
*
|
||||
* Get database statistics (for debugging)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = locationDb.getStats();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
app/api/locations/optimize/route.ts
Normal file
62
app/api/locations/optimize/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getLocationsDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/optimize (ADMIN only)
|
||||
*
|
||||
* Optimize database by running VACUUM and ANALYZE
|
||||
* This reclaims unused space and updates query planner statistics
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
// Super admin only (username "admin")
|
||||
const session = await auth();
|
||||
const username = session?.user?.name || '';
|
||||
if (!session?.user || username !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const db = getLocationsDb();
|
||||
|
||||
// Get size before optimization
|
||||
const sizeBefore = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
// Run VACUUM to reclaim space
|
||||
db.exec('VACUUM');
|
||||
|
||||
// Run ANALYZE to update query planner statistics
|
||||
db.exec('ANALYZE');
|
||||
|
||||
// Get size after optimization
|
||||
const sizeAfter = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
db.close();
|
||||
|
||||
const freedMB = sizeBefore.sizeMB - sizeAfter.sizeMB;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
before: {
|
||||
sizeMB: Math.round(sizeBefore.sizeMB * 100) / 100,
|
||||
},
|
||||
after: {
|
||||
sizeMB: Math.round(sizeAfter.sizeMB * 100) / 100,
|
||||
},
|
||||
freedMB: Math.round(freedMB * 100) / 100,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Optimize error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to optimize database',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
230
app/api/locations/route.ts
Normal file
230
app/api/locations/route.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { LocationResponse } from "@/types/location";
|
||||
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
||||
|
||||
/**
|
||||
* GET /api/locations
|
||||
*
|
||||
* Hybrid approach:
|
||||
* 1. Fetch fresh data from n8n webhook
|
||||
* 2. Store new locations in local SQLite cache
|
||||
* 3. Return filtered data from SQLite (enables 24h+ history)
|
||||
*
|
||||
* Query parameters:
|
||||
* - username: Filter by device tracker ID
|
||||
* - timeRangeHours: Filter by time range (e.g., 1, 3, 6, 12, 24)
|
||||
* - startTime: Custom range start (ISO string)
|
||||
* - endTime: Custom range end (ISO string)
|
||||
* - limit: Maximum number of records (default: 1000)
|
||||
* - sync: Set to 'false' to skip n8n fetch and read only from cache
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user's allowed device IDs for filtering locations
|
||||
const userId = (session.user as any).id;
|
||||
const role = (session.user as any).role;
|
||||
const sessionUsername = session.user.name || '';
|
||||
|
||||
// Get list of device IDs the user is allowed to access
|
||||
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
|
||||
|
||||
// If user has no devices, return empty response
|
||||
if (userDeviceIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
current: null,
|
||||
history: [],
|
||||
total_points: 0,
|
||||
last_updated: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const username = searchParams.get('username') || undefined;
|
||||
const timeRangeHours = searchParams.get('timeRangeHours')
|
||||
? parseInt(searchParams.get('timeRangeHours')!, 10)
|
||||
: undefined;
|
||||
const startTime = searchParams.get('startTime') || undefined;
|
||||
const endTime = searchParams.get('endTime') || undefined;
|
||||
const limit = searchParams.get('limit')
|
||||
? parseInt(searchParams.get('limit')!, 10)
|
||||
: 1000;
|
||||
const sync = searchParams.get('sync') !== 'false'; // Default: true
|
||||
|
||||
// Variable to store n8n data as fallback
|
||||
let n8nData: LocationResponse | null = null;
|
||||
|
||||
// Step 1: Optionally fetch and sync from n8n
|
||||
if (sync) {
|
||||
try {
|
||||
const response = await fetch(N8N_API_URL, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LocationResponse = await response.json();
|
||||
|
||||
// Debug: Log first location from n8n
|
||||
if (data.history && data.history.length > 0) {
|
||||
console.log('[N8N Debug] First location from n8n:', {
|
||||
username: data.history[0].username,
|
||||
speed: data.history[0].speed,
|
||||
speed_type: typeof data.history[0].speed,
|
||||
speed_exists: 'speed' in data.history[0],
|
||||
battery: data.history[0].battery,
|
||||
battery_type: typeof data.history[0].battery,
|
||||
battery_exists: 'battery' in data.history[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
|
||||
if (data.history && Array.isArray(data.history)) {
|
||||
data.history = data.history.map(loc => {
|
||||
// Generate display_time in German locale (Europe/Berlin timezone)
|
||||
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
return {
|
||||
...loc,
|
||||
display_time: displayTime,
|
||||
// Explicit handling: 0 is valid, only undefined/null → null
|
||||
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
|
||||
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Store n8n data for fallback
|
||||
n8nData = data;
|
||||
|
||||
// Store new locations in SQLite
|
||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
||||
// Get latest timestamp from our DB
|
||||
const stats = locationDb.getStats();
|
||||
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
|
||||
|
||||
// Filter for only newer locations
|
||||
const newLocations = data.history.filter(loc =>
|
||||
loc.timestamp > lastLocalTimestamp
|
||||
);
|
||||
|
||||
if (newLocations.length > 0) {
|
||||
const inserted = locationDb.createMany(newLocations as Location[]);
|
||||
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (syncError) {
|
||||
// n8n not reachable - that's ok, we'll use cached data
|
||||
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
|
||||
syncError instanceof Error ? syncError.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read from local SQLite with filters
|
||||
let locations = locationDb.findMany({
|
||||
user_id: 0, // Always filter for MQTT devices
|
||||
username,
|
||||
timeRangeHours,
|
||||
startTime,
|
||||
endTime,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Filter locations to only include user's devices
|
||||
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||
|
||||
// Step 3: If DB is empty, use n8n data as fallback
|
||||
if (locations.length === 0 && n8nData && n8nData.history) {
|
||||
console.log('[API] DB empty, using n8n data as fallback');
|
||||
// Filter n8n data if needed
|
||||
let filteredHistory = n8nData.history;
|
||||
|
||||
// Filter by user's devices
|
||||
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||
|
||||
if (username) {
|
||||
filteredHistory = filteredHistory.filter(loc => loc.username === username);
|
||||
}
|
||||
|
||||
// Apply time filters
|
||||
if (startTime && endTime) {
|
||||
// Custom range
|
||||
filteredHistory = filteredHistory.filter(loc =>
|
||||
loc.timestamp >= startTime && loc.timestamp <= endTime
|
||||
);
|
||||
} else if (timeRangeHours) {
|
||||
// Quick filter
|
||||
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
|
||||
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...n8nData,
|
||||
history: filteredHistory,
|
||||
total_points: filteredHistory.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize locations: Ensure speed, battery, and display_time are correct
|
||||
locations = locations.map(loc => {
|
||||
// Generate display_time if missing or regenerate from timestamp
|
||||
const displayTime = loc.display_time || new Date(loc.timestamp).toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
return {
|
||||
...loc,
|
||||
display_time: displayTime,
|
||||
speed: loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null,
|
||||
battery: loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get actual total count from database (not limited by 'limit' parameter)
|
||||
const stats = locationDb.getStats();
|
||||
|
||||
// Step 4: Return data in n8n-compatible format
|
||||
const response: LocationResponse = {
|
||||
success: true,
|
||||
current: locations.length > 0 ? locations[0] : null,
|
||||
history: locations,
|
||||
total_points: stats.total, // Use actual total from DB, not limited results
|
||||
last_updated: locations.length > 0 ? locations[0].timestamp : new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching locations:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to fetch locations",
|
||||
details: error instanceof Error ? error.message : "Unknown error"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
app/api/locations/stats/route.ts
Normal file
77
app/api/locations/stats/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getLocationsDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/locations/stats
|
||||
*
|
||||
* Get detailed database statistics
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getLocationsDb();
|
||||
|
||||
// Overall stats
|
||||
const totalCount = db.prepare('SELECT COUNT(*) as count FROM Location').get() as { count: number };
|
||||
|
||||
// Time range
|
||||
const timeRange = db.prepare(
|
||||
'SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location'
|
||||
).get() as { oldest: string | null; newest: string | null };
|
||||
|
||||
// Database size
|
||||
const dbSize = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
// WAL mode check
|
||||
const walMode = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };
|
||||
|
||||
// Locations per device
|
||||
const perDevice = db.prepare(`
|
||||
SELECT username, COUNT(*) as count
|
||||
FROM Location
|
||||
WHERE user_id = 0
|
||||
GROUP BY username
|
||||
ORDER BY count DESC
|
||||
`).all() as Array<{ username: string; count: number }>;
|
||||
|
||||
// Locations per day (last 7 days)
|
||||
const perDay = db.prepare(`
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
COUNT(*) as count
|
||||
FROM Location
|
||||
WHERE timestamp >= datetime('now', '-7 days')
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date DESC
|
||||
`).all() as Array<{ date: string; count: number }>;
|
||||
|
||||
// Average locations per day
|
||||
const avgPerDay = perDay.length > 0
|
||||
? Math.round(perDay.reduce((sum, day) => sum + day.count, 0) / perDay.length)
|
||||
: 0;
|
||||
|
||||
db.close();
|
||||
|
||||
return NextResponse.json({
|
||||
total: totalCount.count,
|
||||
oldest: timeRange.oldest,
|
||||
newest: timeRange.newest,
|
||||
sizeMB: Math.round(dbSize.sizeMB * 100) / 100,
|
||||
walMode: walMode.journal_mode,
|
||||
perDevice,
|
||||
perDay,
|
||||
avgPerDay,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get database stats',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/api/locations/sync/route.ts
Normal file
86
app/api/locations/sync/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { locationDb, Location } from '@/lib/db';
|
||||
import type { LocationResponse } from "@/types/location";
|
||||
|
||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
||||
|
||||
/**
|
||||
* POST /api/locations/sync (ADMIN only)
|
||||
*
|
||||
* Manually sync location data from n8n webhook to local SQLite cache.
|
||||
* This fetches all available data from n8n and stores only new records.
|
||||
*
|
||||
* Useful for:
|
||||
* - Initial database population
|
||||
* - Recovery after downtime
|
||||
* - Manual refresh
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
// ADMIN only
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
// Get stats before sync
|
||||
const statsBefore = locationDb.getStats();
|
||||
|
||||
// Fetch from n8n webhook
|
||||
const response = await fetch(N8N_API_URL, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`n8n webhook returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data: LocationResponse = await response.json();
|
||||
|
||||
let insertedCount = 0;
|
||||
|
||||
// Store new locations in SQLite
|
||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
||||
// Get latest timestamp from our DB
|
||||
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
|
||||
|
||||
// Filter for only newer locations
|
||||
const newLocations = data.history.filter(loc =>
|
||||
loc.timestamp > lastLocalTimestamp
|
||||
);
|
||||
|
||||
if (newLocations.length > 0) {
|
||||
insertedCount = locationDb.createMany(newLocations as Location[]);
|
||||
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get stats after sync
|
||||
const statsAfter = locationDb.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: insertedCount,
|
||||
n8nTotal: data.total_points || data.history.length,
|
||||
before: {
|
||||
total: statsBefore.total,
|
||||
newest: statsBefore.newest,
|
||||
},
|
||||
after: {
|
||||
total: statsAfter.total,
|
||||
newest: statsAfter.newest,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to sync locations',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/api/locations/test/route.ts
Normal file
91
app/api/locations/test/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { locationDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/test
|
||||
*
|
||||
* Create a test location entry (for development/testing)
|
||||
* Body: { username, latitude, longitude, speed?, battery? }
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, latitude, longitude, speed, battery } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || latitude === undefined || longitude === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: username, latitude, longitude' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lat = parseFloat(latitude);
|
||||
const lon = parseFloat(longitude);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid latitude or longitude' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Latitude must be between -90 and 90' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lon < -180 || lon > 180) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Longitude must be between -180 and 180' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create location
|
||||
const now = new Date();
|
||||
const location = locationDb.create({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: now.toISOString(),
|
||||
user_id: 0,
|
||||
username: String(username),
|
||||
display_time: now.toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}),
|
||||
chat_id: 0,
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
marker_label: null,
|
||||
battery: battery !== undefined ? Number(battery) : null,
|
||||
speed: speed !== undefined ? Number(speed) : null,
|
||||
});
|
||||
|
||||
if (!location) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create location (possibly duplicate)' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
location,
|
||||
message: 'Test location created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test location creation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create test location' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
146
app/api/mqtt/acl/[id]/route.ts
Normal file
146
app/api/mqtt/acl/[id]/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// API Route für einzelne ACL Regel
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* PATCH /api/mqtt/acl/[id]
|
||||
* Aktualisiere eine ACL Regel
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid ACL rule ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { topic_pattern, permission } = body;
|
||||
|
||||
// Validation
|
||||
if (permission && !['read', 'write', 'readwrite'].includes(permission)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Permission must be read, write, or readwrite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get current ACL rule to check device ownership
|
||||
const userId = (session.user as any).id;
|
||||
const currentRule = mqttAclRuleDb.findByDeviceId(''); // We need to get by ID first
|
||||
const aclRules = mqttAclRuleDb.findAll();
|
||||
const rule = aclRules.find(r => r.id === id);
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ACL rule not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const device = deviceDb.findById(rule.device_id);
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = mqttAclRuleDb.update(id, {
|
||||
topic_pattern,
|
||||
permission
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mqtt/acl/[id]
|
||||
* Lösche eine ACL Regel
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid ACL rule ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get current ACL rule to check device ownership
|
||||
const userId = (session.user as any).id;
|
||||
const aclRules = mqttAclRuleDb.findAll();
|
||||
const rule = aclRules.find(r => r.id === id);
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ACL rule not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const device = deviceDb.findById(rule.device_id);
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = mqttAclRuleDb.delete(id);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/api/mqtt/acl/route.ts
Normal file
104
app/api/mqtt/acl/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// API Route für MQTT ACL Management
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/acl?device_id=xxx
|
||||
* Hole ACL Regeln für ein Device
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const device_id = searchParams.get('device_id');
|
||||
|
||||
if (!device_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const userId = (session.user as any).id;
|
||||
const device = deviceDb.findById(device_id);
|
||||
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const rules = mqttAclRuleDb.findByDeviceId(device_id);
|
||||
return NextResponse.json(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ACL rules:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ACL rules' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/acl
|
||||
* Erstelle neue ACL Regel
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { device_id, topic_pattern, permission } = body;
|
||||
|
||||
// Validierung
|
||||
if (!device_id || !topic_pattern || !permission) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id, topic_pattern, and permission are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['read', 'write', 'readwrite'].includes(permission)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'permission must be one of: read, write, readwrite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const userId = (session.user as any).id;
|
||||
const device = deviceDb.findById(device_id);
|
||||
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const rule = mqttAclRuleDb.create({
|
||||
device_id,
|
||||
topic_pattern,
|
||||
permission
|
||||
});
|
||||
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal file
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// API Route für einzelne MQTT Credentials
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { hashPassword } from '@/lib/mosquitto-sync';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/credentials/[device_id]
|
||||
* Hole MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const credential = mqttCredentialDb.findByDeviceId(device_id);
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(credential);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/mqtt/credentials/[device_id]
|
||||
* Aktualisiere MQTT Credentials
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { regenerate_password, enabled } = body;
|
||||
|
||||
const credential = mqttCredentialDb.findByDeviceId(device_id);
|
||||
if (!credential) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let newPassword: string | undefined;
|
||||
let updateData: any = {};
|
||||
|
||||
// Regeneriere Passwort wenn angefordert
|
||||
if (regenerate_password) {
|
||||
newPassword = randomBytes(16).toString('base64');
|
||||
const password_hash = await hashPassword(newPassword);
|
||||
updateData.mqtt_password_hash = password_hash;
|
||||
}
|
||||
|
||||
// Update enabled Status
|
||||
if (enabled !== undefined) {
|
||||
updateData.enabled = enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
// Update Credentials
|
||||
const updated = mqttCredentialDb.update(device_id, updateData);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
// Sende neues Passwort nur wenn regeneriert
|
||||
...(newPassword && { mqtt_password: newPassword })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mqtt/credentials/[device_id]
|
||||
* Lösche MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Lösche zuerst alle ACL Regeln
|
||||
mqttAclRuleDb.deleteByDeviceId(device_id);
|
||||
|
||||
// Dann lösche Credentials
|
||||
const deleted = mqttCredentialDb.delete(device_id);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/api/mqtt/credentials/route.ts
Normal file
126
app/api/mqtt/credentials/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// API Route für MQTT Credentials Management
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
import { hashPassword } from '@/lib/mosquitto-sync';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/credentials
|
||||
* Liste alle MQTT Credentials
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const credentials = mqttCredentialDb.findAll();
|
||||
|
||||
// Filter credentials to only show user's devices
|
||||
const credentialsWithDevices = credentials
|
||||
.map(cred => {
|
||||
const device = deviceDb.findById(cred.device_id);
|
||||
return {
|
||||
...cred,
|
||||
device_name: device?.name || 'Unknown Device',
|
||||
device_owner: device?.ownerId
|
||||
};
|
||||
})
|
||||
.filter(cred => cred.device_owner === userId);
|
||||
|
||||
return NextResponse.json(credentialsWithDevices);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/credentials
|
||||
* Erstelle neue MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { device_id, mqtt_username, mqtt_password, auto_generate } = body;
|
||||
|
||||
// Validierung
|
||||
if (!device_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfe ob Device existiert
|
||||
const device = deviceDb.findById(device_id);
|
||||
if (!device) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfe ob bereits Credentials existieren
|
||||
const existing = mqttCredentialDb.findByDeviceId(device_id);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'MQTT credentials already exist for this device' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generiere oder verwende übergebene Credentials
|
||||
let username = mqtt_username;
|
||||
let password = mqtt_password;
|
||||
|
||||
if (auto_generate || !username) {
|
||||
// Generiere Username: device_[device-id]_[random]
|
||||
username = `device_${device_id}_${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
if (auto_generate || !password) {
|
||||
// Generiere sicheres Passwort
|
||||
password = randomBytes(16).toString('base64');
|
||||
}
|
||||
|
||||
// Hash Passwort
|
||||
const password_hash = await hashPassword(password);
|
||||
|
||||
// Erstelle Credentials
|
||||
const credential = mqttCredentialDb.create({
|
||||
device_id,
|
||||
mqtt_username: username,
|
||||
mqtt_password_hash: password_hash,
|
||||
enabled: 1
|
||||
});
|
||||
|
||||
// Erstelle Default ACL Regel
|
||||
mqttAclRuleDb.createDefaultRule(device_id);
|
||||
|
||||
return NextResponse.json({
|
||||
...credential,
|
||||
// Sende Plaintext-Passwort nur bei Erstellung zurück
|
||||
mqtt_password: password,
|
||||
device_name: device.name
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/api/mqtt/send-credentials/route.ts
Normal file
90
app/api/mqtt/send-credentials/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { deviceDb, userDb } from '@/lib/db';
|
||||
|
||||
// POST /api/mqtt/send-credentials - Send MQTT credentials via email
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can send credentials
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { deviceId, mqttUsername, mqttPassword } = body;
|
||||
|
||||
if (!deviceId || !mqttUsername || !mqttPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: deviceId, mqttUsername, mqttPassword' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get device info
|
||||
const device = deviceDb.findById(deviceId);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get device owner
|
||||
if (!device.ownerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device has no owner assigned' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const owner = userDb.findById(device.ownerId);
|
||||
if (!owner) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device owner not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!owner.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device owner has no email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse broker URL from environment or use default
|
||||
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
|
||||
const brokerHost = brokerUrl.replace(/^mqtt:\/\//, '').replace(/:\d+$/, '');
|
||||
const brokerPortMatch = brokerUrl.match(/:(\d+)$/);
|
||||
const brokerPort = brokerPortMatch ? brokerPortMatch[1] : '1883';
|
||||
|
||||
// Send email
|
||||
await emailService.sendMqttCredentialsEmail({
|
||||
email: owner.email,
|
||||
deviceName: device.name,
|
||||
deviceId: device.id,
|
||||
mqttUsername,
|
||||
mqttPassword,
|
||||
brokerUrl,
|
||||
brokerHost,
|
||||
brokerPort,
|
||||
});
|
||||
|
||||
console.log(`[MQTT] Credentials sent via email to ${owner.email} for device ${device.name}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Credentials sent to ${owner.email}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending MQTT credentials email:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to send email' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/mqtt/sync/route.ts
Normal file
50
app/api/mqtt/sync/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// API Route für Mosquitto Configuration Sync
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { syncMosquittoConfig, getMosquittoSyncStatus } from '@/lib/mosquitto-sync';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/sync
|
||||
* Hole den aktuellen Sync Status
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const status = getMosquittoSyncStatus();
|
||||
return NextResponse.json(status || { pending_changes: 0, last_sync_status: 'unknown' });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sync status:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sync status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/sync
|
||||
* Trigger Mosquitto Configuration Sync
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await syncMosquittoConfig();
|
||||
return NextResponse.json(result, {
|
||||
status: result.success ? 200 : 500
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync Mosquitto config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to sync Mosquitto configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/system/status/route.ts
Normal file
42
app/api/system/status/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/system/status
|
||||
*
|
||||
* Returns system status information
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const uptimeSeconds = process.uptime();
|
||||
|
||||
// Calculate days, hours, minutes, seconds
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(uptimeSeconds % 60);
|
||||
|
||||
return NextResponse.json({
|
||||
uptime: {
|
||||
total: Math.floor(uptimeSeconds),
|
||||
formatted: `${days}d ${hours}h ${minutes}m ${seconds}s`,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
},
|
||||
memory: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
},
|
||||
nodejs: process.version,
|
||||
platform: process.platform,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('System status error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get system status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
209
app/api/users/[id]/route.ts
Normal file
209
app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userDb } from "@/lib/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// GET /api/users/[id] - Get single user (admin only)
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can view users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can view any user details
|
||||
// ADMIN users can only view their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json({ user: safeUser });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/users/[id] - Update user (admin only)
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can update users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can modify any user
|
||||
// ADMIN users can only modify their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Cannot modify this user" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username, email, password, role } = body;
|
||||
|
||||
// Validation
|
||||
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be ADMIN or VIEWER" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username is taken by another user
|
||||
if (username && username !== user.username) {
|
||||
const existing = userDb.findByUsername(username);
|
||||
if (existing && existing.id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
passwordHash?: string;
|
||||
role?: string;
|
||||
} = {};
|
||||
|
||||
if (username !== undefined) updateData.username = username;
|
||||
if (email !== undefined) updateData.email = email;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
if (password) {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = userDb.update(id, updateData);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash, ...safeUser } = updated;
|
||||
|
||||
return NextResponse.json({ user: safeUser });
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/users/[id] - Delete user (admin only)
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can delete users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can delete any user
|
||||
// ADMIN users can only delete their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Cannot delete this user" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent deleting yourself
|
||||
if ((session.user as any).id === id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot delete your own account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const success = userDb.delete(id);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "User deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
155
app/api/users/route.ts
Normal file
155
app/api/users/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userDb } from "@/lib/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
// GET /api/users - List all users (admin only)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can view users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can see all users
|
||||
// Other ADMIN users see only their created viewers (parent-child relationship)
|
||||
let users: any[];
|
||||
|
||||
if (currentUsername === 'admin') {
|
||||
// Super admin sees all users
|
||||
users = userDb.findAll();
|
||||
} else if ((session.user as any).role === 'ADMIN') {
|
||||
// ADMIN users see only their child viewers
|
||||
users = userDb.findAll({ parentUserId: currentUserId });
|
||||
} else {
|
||||
// VIEWER users see nobody
|
||||
users = [];
|
||||
}
|
||||
|
||||
// Remove password hashes from response
|
||||
const safeUsers = users.map(({ passwordHash, ...user }) => user);
|
||||
|
||||
return NextResponse.json({ users: safeUsers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users - Create new user (admin only)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can create users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username, email, password, role } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: username, password" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be ADMIN or VIEWER" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existing = userDb.findByUsername(username);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Determine parent_user_id
|
||||
// If current user is not "admin", set parent_user_id to current user's ID
|
||||
// If creating a VIEWER, set parent_user_id to current user's ID
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
let parent_user_id: string | null = null;
|
||||
|
||||
if (currentUsername !== 'admin') {
|
||||
// Non-admin ADMIN users create viewers that belong to them
|
||||
parent_user_id = currentUserId;
|
||||
|
||||
// Force role to VIEWER for non-admin ADMIN users
|
||||
if (role && role !== 'VIEWER') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only create VIEWER users' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = userDb.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
email: email || null,
|
||||
passwordHash,
|
||||
role: role || 'VIEWER',
|
||||
parent_user_id,
|
||||
});
|
||||
|
||||
// Send welcome email (don't fail if email fails)
|
||||
if (email) {
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username,
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
temporaryPassword: password, // Send the original password
|
||||
});
|
||||
console.log('[UserCreate] Welcome email sent to:', email);
|
||||
} catch (emailError) {
|
||||
console.error('[UserCreate] Failed to send welcome email:', emailError);
|
||||
// Don't fail user creation if email fails
|
||||
}
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json({ user: safeUser }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user