Add dockre file
This commit is contained in:
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user