Add dockre file

This commit is contained in:
Aditya Siregar
2025-08-15 23:03:15 +07:00
commit 486f45bb92
106 changed files with 18736 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import axios from 'axios'
import { API_CONFIG } from './config'
// Create Axios instance with default configuration
export const apiClient = axios.create({
baseURL: API_CONFIG.BASE_URL,
timeout: 10000, // 10 seconds timeout
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth header
apiClient.interceptors.request.use(
(config) => {
// Get token from localStorage
const token = localStorage.getItem("auth_token")
// Add Authorization header if token exists
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Only add static auth header if it has a value (for login requests)
if (API_CONFIG.AUTH_HEADER && API_CONFIG.AUTH_HEADER.trim()) {
config.headers.Authorization = API_CONFIG.AUTH_HEADER
}
// Log request for debugging
console.log('API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
fullURL: `${config.baseURL}${config.url}`,
hasAuthHeader: !!config.headers.Authorization,
authType: token ? 'Bearer Token' : API_CONFIG.AUTH_HEADER ? 'Static Header' : 'None',
})
return config
},
(error) => {
console.error('Request interceptor error:', error)
return Promise.reject(error)
}
)
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => {
console.log('API Response:', {
status: response.status,
statusText: response.statusText,
url: response.config.url,
data: response.data,
})
return response
},
(error) => {
console.error('API Error:', {
status: error.response?.status,
statusText: error.response?.statusText,
message: error.message,
url: error.config?.url,
data: error.response?.data,
})
return Promise.reject(error)
}
)
export default apiClient
+81
View File
@@ -0,0 +1,81 @@
import { SignJWT, jwtVerify } from "jose"
import { cookies } from "next/headers"
import { APP_CONFIG } from "./config"
const secretKey = APP_CONFIG.JWT_SECRET
const key = new TextEncoder().encode(secretKey)
export interface SessionPayload {
userId: string
username: string
role: string
expiresAt: Date
}
export async function encrypt(payload: SessionPayload) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(key)
}
export async function decrypt(input: string): Promise<SessionPayload> {
const { payload } = await jwtVerify(input, key, {
algorithms: ["HS256"],
})
return payload as SessionPayload
}
export async function createSession(userId: string, username: string, role: string) {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
const session = await encrypt({ userId, username, role, expiresAt })
cookies().set("session", session, {
expires: expiresAt,
httpOnly: true,
secure: APP_CONFIG.NODE_ENV === "production",
sameSite: "lax",
path: "/",
})
}
export async function verifySession() {
const cookie = cookies().get("session")?.value
if (!cookie) return null
try {
const session = await decrypt(cookie)
if (new Date(session.expiresAt) < new Date()) {
return null
}
return session
} catch {
return null
}
}
export async function deleteSession() {
cookies().delete("session")
}
// Rate limiting store (in production, use Redis)
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
export function rateLimit(identifier: string, limit = 5, windowMs: number = 15 * 60 * 1000) {
const now = Date.now()
const key = identifier
const record = rateLimitStore.get(key)
if (!record || now > record.resetTime) {
rateLimitStore.set(key, { count: 1, resetTime: now + windowMs })
return { success: true, remaining: limit - 1 }
}
if (record.count >= limit) {
return { success: false, remaining: 0, resetTime: record.resetTime }
}
record.count++
return { success: true, remaining: limit - record.count }
}
+35
View File
@@ -0,0 +1,35 @@
// API Configuration
// You can modify these values directly here if environment variables are not working
const BACKEND_URL = 'http://localhost:4000' // Change this to your backend URL
const AUTH_HEADER = '' // Change this to your actual auth header
export const API_CONFIG = {
BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || BACKEND_URL,
AUTH_HEADER: process.env.NEXT_PUBLIC_API_AUTH_HEADER || AUTH_HEADER,
ENDPOINTS: {
LOGIN: '/api/v1/auth/login',
LOGOUT: '/api/v1/auth/logout',
VERIFY: '/api/v1/auth/verify',
VOTE_EVENTS: '/api/v1/vote-events',
CANDIDATES: '/api/v1/candidates',
VOTES: '/api/v1/votes',
FILES: '/api/v1/files/documents',
RESULTS: '/api/v1/vote-events',
}
}
// Application Configuration
export const APP_CONFIG = {
BASE_URL: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000',
JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key-change-this',
NODE_ENV: process.env.NODE_ENV || 'development',
}
// Debug logging
console.log('🚀 API Config loaded:', {
BASE_URL: API_CONFIG.BASE_URL,
AUTH_HEADER: API_CONFIG.AUTH_HEADER ? '*** SET ***' : '❌ NOT SET',
ENDPOINTS: API_CONFIG.ENDPOINTS,
FULL_LOGIN_URL: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.LOGIN}`,
FULL_VOTE_EVENTS_URL: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.VOTE_EVENTS}`,
})
+441
View File
@@ -0,0 +1,441 @@
import { Resend } from "resend"
const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null
interface EmailUser {
name: string
email: string
username: string
memberId: string
department: string
}
interface WelcomeEmailData extends EmailUser {
password: string
loginUrl: string
}
interface StatusChangeEmailData extends EmailUser {
status: "verified" | "rejected" | "pending"
loginUrl: string
}
interface VotingReminderEmailData extends EmailUser {
eventTitle: string
eventEndDate: string
votingUrl: string
}
export class EmailService {
private isDevelopment = process.env.NODE_ENV === "development"
private fromEmail = process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev"
private baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"
async sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean> {
try {
// Only attempt to send email if RESEND_API_KEY is configured
if (!process.env.RESEND_API_KEY || !resend) {
console.log("RESEND_API_KEY not configured, skipping email send")
return true // Return success to not block user creation
}
const { data: result, error } = await resend.emails.send({
from: this.fromEmail,
to: [data.email],
subject: "Selamat Datang di Platform E-Voting METI",
html: this.getWelcomeEmailTemplate(data),
})
if (error) {
console.error("Error sending welcome email:", error)
// Don't block user creation if email fails
return true
}
console.log("Welcome email sent successfully:", result?.id)
return true
} catch (error) {
console.error("Failed to send welcome email:", error)
// Return true to not block user creation in development
return true
}
}
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
try {
// Only attempt to send email if RESEND_API_KEY is configured
if (!process.env.RESEND_API_KEY || !resend) {
console.log("RESEND_API_KEY not configured, skipping email send")
return true // Return success to not block user creation
}
const subject = this.getStatusEmailSubject(data.status)
const { data: result, error } = await resend.emails.send({
from: this.fromEmail,
to: [data.email],
subject,
html: this.getStatusChangeEmailTemplate(data),
})
if (error) {
console.error("Error sending status change email:", error)
// Don't block user creation if email fails
return true
}
console.log("Status change email sent successfully:", result?.id)
return true
} catch (error) {
console.error("Failed to send status change email:", error)
// Return true to not block user creation in development
return true
}
}
async sendVotingReminderEmail(data: VotingReminderEmailData): Promise<boolean> {
try {
// Only attempt to send email if RESEND_API_KEY is configured
if (!process.env.RESEND_API_KEY || !resend) {
console.log("RESEND_API_KEY not configured, skipping email send")
return true // Return success to not block user creation
}
const { data: result, error } = await resend.emails.send({
from: this.fromEmail,
to: [data.email],
subject: `Reminder: Voting ${data.eventTitle} - METI`,
html: this.getVotingReminderEmailTemplate(data),
})
if (error) {
console.error("Error sending voting reminder email:", error)
// Don't block user creation if email fails
return true
}
console.log("Voting reminder email sent successfully:", result?.id)
return true
} catch (error) {
console.error("Failed to send voting reminder email:", error)
// Return true to not block user creation in development
return true
}
}
private getStatusEmailSubject(status: string): string {
switch (status) {
case "verified":
return "Akun Anda Telah Diverifikasi - METI E-Voting"
case "rejected":
return "Status Verifikasi Akun - METI E-Voting"
case "pending":
return "Status Akun Ditangguhkan - METI E-Voting"
default:
return "Update Status Akun - METI E-Voting"
}
}
private getWelcomeEmailTemplate(data: WelcomeEmailData): string {
return `
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Selamat Datang di METI E-Voting</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.logo { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.credentials-box { background: white; border: 2px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 20px 0; }
.credential-item { display: flex; justify-content: space-between; margin: 10px 0; padding: 10px; background: #f1f5f9; border-radius: 5px; }
.credential-label { font-weight: bold; color: #475569; }
.credential-value { font-family: monospace; background: #1e293b; color: #f1f5f9; padding: 4px 8px; border-radius: 4px; }
.button { display: inline-block; background: #1e40af; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: bold; }
.button:hover { background: #1d4ed8; }
.warning { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 6px; margin: 20px 0; }
.footer { text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 14px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">🗳️ METI E-Voting Platform</div>
<p>Sistem Pemilihan Elektronik METI (New & Renewable Energy)</p>
</div>
<div class="content">
<h2>Selamat Datang, ${data.name}!</h2>
<p>Akun Anda telah berhasil dibuat di Platform E-Voting METI. Berikut adalah informasi akun Anda:</p>
<div class="credentials-box">
<h3>📋 Informasi Akun</h3>
<div class="credential-item">
<span class="credential-label">Nama Lengkap:</span>
<span>${data.name}</span>
</div>
<div class="credential-item">
<span class="credential-label">ID Anggota:</span>
<span class="credential-value">${data.memberId}</span>
</div>
<div class="credential-item">
<span class="credential-label">Departemen:</span>
<span>${data.department}</span>
</div>
<div class="credential-item">
<span class="credential-label">Email:</span>
<span>${data.email}</span>
</div>
</div>
<div class="credentials-box">
<h3>🔐 Kredensial Login</h3>
<div class="credential-item">
<span class="credential-label">Username:</span>
<span class="credential-value">${data.username}</span>
</div>
<div class="credential-item">
<span class="credential-label">Password:</span>
<span class="credential-value">${data.password}</span>
</div>
</div>
<div class="warning">
<strong>⚠️ Penting:</strong>
<ul>
<li>Akun Anda saat ini berstatus <strong>PENDING</strong> dan menunggu verifikasi admin</li>
<li>Anda belum dapat login hingga akun diverifikasi</li>
<li>Simpan kredensial login ini dengan aman</li>
<li>Jangan bagikan informasi login kepada orang lain</li>
</ul>
</div>
<div style="text-align: center;">
<a href="${data.loginUrl}" class="button">🚀 Login ke Platform</a>
</div>
<h3>📝 Langkah Selanjutnya:</h3>
<ol>
<li>Tunggu email konfirmasi verifikasi dari admin</li>
<li>Setelah diverifikasi, login menggunakan kredensial di atas</li>
<li>Ikuti event voting yang tersedia</li>
<li>Berikan suara Anda untuk kandidat pilihan</li>
</ol>
<p>Jika Anda memiliki pertanyaan, silakan hubungi administrator METI.</p>
</div>
<div class="footer">
<p>Email ini dikirim secara otomatis oleh sistem METI E-Voting Platform</p>
<p>© 2024 METI (New & Renewable Energy). All rights reserved.</p>
</div>
</body>
</html>
`
}
private getStatusChangeEmailTemplate(data: StatusChangeEmailData): string {
const statusInfo = this.getStatusInfo(data.status)
return `
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Update Status Akun - METI E-Voting</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: ${statusInfo.headerColor}; color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.logo { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.status-box { background: white; border: 2px solid ${statusInfo.borderColor}; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center; }
.status-icon { font-size: 48px; margin-bottom: 15px; }
.status-title { font-size: 24px; font-weight: bold; color: ${statusInfo.textColor}; margin-bottom: 10px; }
.button { display: inline-block; background: ${statusInfo.buttonColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: bold; }
.info-box { background: ${statusInfo.bgColor}; border: 1px solid ${statusInfo.borderColor}; padding: 15px; border-radius: 6px; margin: 20px 0; }
.footer { text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 14px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">🗳️ METI E-Voting Platform</div>
<p>Update Status Keanggotaan</p>
</div>
<div class="content">
<h2>Halo, ${data.name}!</h2>
<div class="status-box">
<div class="status-icon">${statusInfo.icon}</div>
<div class="status-title">${statusInfo.title}</div>
<p>${statusInfo.description}</p>
</div>
<div class="info-box">
<h3>📋 Informasi Akun</h3>
<p><strong>Nama:</strong> ${data.name}</p>
<p><strong>ID Anggota:</strong> ${data.memberId}</p>
<p><strong>Departemen:</strong> ${data.department}</p>
<p><strong>Status Saat Ini:</strong> <strong style="color: ${statusInfo.textColor}">${statusInfo.statusText}</strong></p>
</div>
${statusInfo.actionText}
${
data.status === "verified"
? `
<div style="text-align: center;">
<a href="${data.loginUrl}" class="button">🚀 Login & Mulai Voting</a>
</div>
`
: ""
}
<p>Jika Anda memiliki pertanyaan tentang status akun ini, silakan hubungi administrator METI.</p>
</div>
<div class="footer">
<p>Email ini dikirim secara otomatis oleh sistem METI E-Voting Platform</p>
<p>© 2024 METI (New & Renewable Energy). All rights reserved.</p>
</div>
</body>
</html>
`
}
private getVotingReminderEmailTemplate(data: VotingReminderEmailData): string {
return `
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder Voting - METI E-Voting</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.logo { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.event-box { background: white; border: 2px solid #fbbf24; border-radius: 8px; padding: 20px; margin: 20px 0; }
.button { display: inline-block; background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: bold; }
.urgent { background: #fef2f2; border: 1px solid #fca5a5; padding: 15px; border-radius: 6px; margin: 20px 0; }
.footer { text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 14px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">⏰ METI E-Voting Platform</div>
<p>Reminder Voting</p>
</div>
<div class="content">
<h2>Halo, ${data.name}!</h2>
<div class="urgent">
<h3>🚨 Jangan Lupa Voting!</h3>
<p>Ini adalah pengingat bahwa Anda belum memberikan suara untuk event voting yang sedang berlangsung.</p>
</div>
<div class="event-box">
<h3>📊 Informasi Event</h3>
<p><strong>Event:</strong> ${data.eventTitle}</p>
<p><strong>Batas Waktu:</strong> ${new Date(data.eventEndDate).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}</p>
<p><strong>Status:</strong> Belum Voting</p>
</div>
<div style="text-align: center;">
<a href="${data.votingUrl}" class="button">🗳️ Voting Sekarang</a>
</div>
<h3>💡 Mengapa Suara Anda Penting?</h3>
<ul>
<li>Setiap suara menentukan masa depan METI</li>
<li>Partisipasi Anda sangat berharga</li>
<li>Proses demokratis membutuhkan keterlibatan semua anggota</li>
</ul>
<p><strong>Catatan:</strong> Pastikan Anda voting sebelum batas waktu berakhir. Setelah event ditutup, Anda tidak dapat lagi memberikan suara.</p>
</div>
<div class="footer">
<p>Email ini dikirim secara otomatis oleh sistem METI E-Voting Platform</p>
<p>© 2024 METI (New & Renewable Energy). All rights reserved.</p>
</div>
</body>
</html>
`
}
private getStatusInfo(status: string) {
switch (status) {
case "verified":
return {
icon: "✅",
title: "Akun Terverifikasi!",
description: "Selamat! Akun Anda telah diverifikasi dan dapat digunakan untuk voting.",
statusText: "TERVERIFIKASI",
headerColor: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
borderColor: "#10b981",
textColor: "#059669",
buttonColor: "#059669",
bgColor: "#ecfdf5",
actionText:
"<p><strong>✨ Apa yang bisa Anda lakukan sekarang:</strong></p><ul><li>Login ke platform e-voting</li><li>Ikuti event voting yang tersedia</li><li>Berikan suara untuk kandidat pilihan Anda</li><li>Lihat hasil voting real-time</li></ul>",
}
case "rejected":
return {
icon: "❌",
title: "Verifikasi Ditolak",
description:
"Maaf, verifikasi akun Anda ditolak. Silakan hubungi administrator untuk informasi lebih lanjut.",
statusText: "DITOLAK",
headerColor: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
borderColor: "#ef4444",
textColor: "#dc2626",
buttonColor: "#dc2626",
bgColor: "#fef2f2",
actionText:
"<p><strong>📞 Langkah selanjutnya:</strong></p><ul><li>Hubungi administrator METI</li><li>Tanyakan alasan penolakan</li><li>Perbaiki dokumen atau informasi yang diperlukan</li><li>Ajukan ulang jika memungkinkan</li></ul>",
}
case "pending":
return {
icon: "⏳",
title: "Status Ditangguhkan",
description: "Akun Anda sementara ditangguhkan dan sedang dalam review ulang.",
statusText: "DITANGGUHKAN",
headerColor: "linear-gradient(135deg, #d97706 0%, #f59e0b 100%)",
borderColor: "#f59e0b",
textColor: "#d97706",
buttonColor: "#d97706",
bgColor: "#fffbeb",
actionText:
"<p><strong>⏰ Yang perlu Anda ketahui:</strong></p><ul><li>Akun Anda sedang dalam review</li><li>Anda tidak dapat login sementara waktu</li><li>Tunggu email konfirmasi lebih lanjut</li><li>Hubungi admin jika ada pertanyaan</li></ul>",
}
default:
return {
icon: "❓",
title: "Status Tidak Dikenal",
description: "Status akun Anda tidak dapat diidentifikasi.",
statusText: "TIDAK DIKENAL",
headerColor: "linear-gradient(135deg, #6b7280 0%, #9ca3af 100%)",
borderColor: "#9ca3af",
textColor: "#6b7280",
buttonColor: "#6b7280",
bgColor: "#f9fafb",
actionText: "<p>Silakan hubungi administrator untuk klarifikasi status akun Anda.</p>",
}
}
}
}
export const emailService = new EmailService()
+61
View File
@@ -0,0 +1,61 @@
export class SecurityUtils {
// Input sanitization to prevent XSS
static sanitizeInput(input: string): string {
if (!input) return ""
return input
.replace(/[<>]/g, "") // Remove < and >
.replace(/javascript:/gi, "") // Remove javascript: protocol
.replace(/on\w+=/gi, "") // Remove event handlers
.trim()
}
// Email validation
static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// Password strength validation (simplified)
static validatePasswordStrength(password: string): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
if (password.length < 4) {
errors.push("Password minimal 4 karakter")
}
return {
isValid: errors.length === 0,
errors,
}
}
// Rate limiting helper
static createRateLimiter() {
const attempts = new Map<string, { count: number; resetTime: number }>()
return {
isAllowed: (key: string, maxAttempts = 5, windowMs: number = 15 * 60 * 1000): boolean => {
const now = Date.now()
const record = attempts.get(key)
if (!record || now > record.resetTime) {
attempts.set(key, { count: 1, resetTime: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
},
reset: (key: string) => {
attempts.delete(key)
},
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}