Mail Service & Magic Link Authentication — Implementation Plan
DerinFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a global email service (React Email + Nodemailer) and admin magic link login.
Architecture: Layered mail service under lib/mail/ — transport (Nodemailer SMTP), templates (React Email JSX), i18n (locale dictionaries), tokens (DB-backed magic link tokens). Admin login page gets a hybrid UI (password + magic link option). Admin auth uses iron-session + AdminSession DB records (NOT NextAuth).
Tech Stack: React Email (@react-email/components), Nodemailer, Prisma (PostgreSQL), iron-session, Next.js 16 App Router API routes.
Spec: docs/superpowers/specs/2026-04-01-mail-service-magic-link-design.md
File Structure
Bölüm başlığı “File Structure”| File | Action | Responsibility |
|---|---|---|
lib/mail/transport.ts | Create | Singleton pooled Nodemailer SMTP transporter |
lib/mail/i18n.ts | Create | Per-template locale string dictionaries |
lib/mail/templates/base-layout.tsx | Create | Shared branded email layout (header, footer, colors) |
lib/mail/templates/magic-link.tsx | Create | Magic link email template |
lib/mail/templates/index.ts | Create | Template registry — type-safe render function |
lib/mail/service.ts | Create | Main sendMail() orchestrator |
lib/mail/tokens.ts | Create | generateMagicToken() / verifyMagicToken() — DB-backed |
prisma/schema.prisma | Edit | Add MagicLinkToken model |
.env | Edit | Add SMTP + magic link env vars |
app/api/admin/auth/magic-link/send/route.ts | Create | API: generate token + send email |
app/api/admin/auth/magic-link/verify/route.ts | Create | API: verify token + create session |
app/[locale]/admin/login/page.tsx | Edit | Hybrid UI — password + magic link option |
Task 1: Install Dependencies & Configure Environment
Bölüm başlığı “Task 1: Install Dependencies & Configure Environment”Files:
-
Modify:
package.json -
Modify:
.env -
Step 1: Install npm packages
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpm install @react-email/components nodemailernpm install -D @types/nodemailer- Step 2: Add SMTP and magic link env vars to
.env
Add these lines at the end of .env:
# ─── Mail Service ────────────────────────────────────────────SMTP_HOST=ecutuningportal.comSMTP_PORT=465SMTP_SECURE=trueSMTP_USER=no-reply@ecutuningportal.comSMTP_PASS=zbUU460s2lgUFs5KSMTP_FROM=ECU Tuning Portal <no-reply@ecutuningportal.com>
# ─── Magic Link ──────────────────────────────────────────────MAGIC_LINK_BASE_URL=https://ecutuningportal.com- Step 3: Commit
git add package.json package-lock.json .envgit commit -m "chore: React Email + Nodemailer bağımlılıkları ve SMTP yapılandırması"Task 2: Add MagicLinkToken Prisma Model
Bölüm başlığı “Task 2: Add MagicLinkToken Prisma Model”Files:
-
Modify:
prisma/schema.prisma -
Step 1: Add MagicLinkToken model to schema
Add this block at the end of prisma/schema.prisma, after the GalleryImage model:
// ─── Magic Link Tokens ─────────────────────────────────────model MagicLinkToken { id Int @id @default(autoincrement()) email String token String @unique expiresAt DateTime usedAt DateTime? ipAddress String? userAgent String? createdAt DateTime @default(now())
@@index([email]) @@index([token])}- Step 2: Generate and apply migration
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx prisma migrate dev --name add_magic_link_tokenExpected: Migration created and applied, MagicLinkToken table created in PostgreSQL.
- Step 3: Verify Prisma client generation
npx prisma generateExpected: Prisma client generated successfully with MagicLinkToken model accessible.
- Step 4: Commit
git add prisma/git commit -m "db: MagicLinkToken modeli — magic link auth token'ları"Task 3: Create Mail Transport (Nodemailer SMTP)
Bölüm başlığı “Task 3: Create Mail Transport (Nodemailer SMTP)”Files:
-
Create:
lib/mail/transport.ts -
Step 1: Create the transport singleton
Create lib/mail/transport.ts:
import nodemailer from 'nodemailer';
/** * Singleton pooled Nodemailer SMTP transporter. * Uses Plesk SMTP on port 465 (implicit TLS). * * Connection pooling keeps a persistent connection open, * reducing latency for consecutive emails. */
let transporter: nodemailer.Transporter | null = null;
export function getTransporter(): nodemailer.Transporter { if (transporter) return transporter;
transporter = nodemailer.createTransport({ pool: true, host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 465, secure: process.env.SMTP_SECURE === 'true', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, tls: { // Plesk self-signed certificates — required for some Plesk setups rejectUnauthorized: false, }, });
return transporter;}
/** * Verify SMTP connection is working. * Call this during startup or health checks. */export async function verifyTransport(): Promise<boolean> { try { await getTransporter().verify(); return true; } catch (error) { console.error('[Mail] SMTP connection failed:', error); return false; }}- Step 2: Verify the file compiles
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx tsc --noEmit lib/mail/transport.ts 2>&1 || echo "Check for type errors"- Step 3: Commit
git add lib/mail/transport.tsgit commit -m "feat(mail): Nodemailer SMTP transport — pooled, SSL, Plesk uyumlu"Task 4: Create i18n Translation Dictionaries
Bölüm başlığı “Task 4: Create i18n Translation Dictionaries”Files:
-
Create:
lib/mail/i18n.ts -
Step 1: Create the i18n module
Create lib/mail/i18n.ts:
/** * Per-template, per-locale translation dictionaries. * * Adding a new locale: add a key under each template. * Adding a new template: add a new top-level key with all locale entries. */
export interface TemplateStrings { subject: string; [key: string]: string;}
type TranslationMap = Record<string, Record<string, TemplateStrings>>;
const translations: TranslationMap = { 'magic-link': { en: { subject: 'Sign in to ECU Tuning Portal', greeting: 'Hello {name},', body: 'Click the button below to sign in to your account. This link is valid for 15 minutes and can only be used once.', button: 'Sign In', warning: 'If you did not request this link, you can safely ignore this email.', ip_notice: 'Requested from IP: {ipAddress}', expiry_note: 'This link expires in 15 minutes.', }, tr: { subject: 'ECU Tuning Portal Giriş', greeting: 'Merhaba {name},', body: 'Hesabınıza giriş yapmak için aşağıdaki butona tıklayın. Bu link 15 dakika geçerlidir ve yalnızca bir kez kullanılabilir.', button: 'Giriş Yap', warning: 'Bu linki siz talep etmediyseniz, bu e-postayı güvenle görmezden gelebilirsiniz.', ip_notice: 'Talep edilen IP: {ipAddress}', expiry_note: 'Bu link 15 dakika içinde geçersiz olacaktır.', }, },};
/** * Get translation strings for a template + locale. * Falls back to 'en' if locale not found. */export function getTranslations(template: string, locale: string): TemplateStrings { const templateStrings = translations[template]; if (!templateStrings) { throw new Error(`[Mail i18n] Unknown template: ${template}`); } return templateStrings[locale] ?? templateStrings['en'];}
/** * Replace `{key}` placeholders in a string with values from data. */export function interpolate(text: string, data: Record<string, string>): string { return text.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? `{${key}}`);}- Step 2: Commit
git add lib/mail/i18n.tsgit commit -m "feat(mail): i18n çeviri sözlükleri — EN + TR, genişletilebilir yapı"Task 5: Create React Email Templates
Bölüm başlığı “Task 5: Create React Email Templates”Files:
-
Create:
lib/mail/templates/base-layout.tsx -
Create:
lib/mail/templates/magic-link.tsx -
Create:
lib/mail/templates/index.ts -
Step 1: Create the base layout
Create lib/mail/templates/base-layout.tsx:
import { Html, Head, Body, Container, Section, Img, Text, Hr, Tailwind,} from '@react-email/components';import type { ReactNode } from 'react';
interface BaseLayoutProps { preview: string; children: ReactNode; locale?: string;}
export function BaseLayout({ preview, children, locale = 'en' }: BaseLayoutProps) { const year = new Date().getFullYear(); const footerText = locale === 'tr' ? `Bu e-posta otomatik olarak gönderilmiştir. Lütfen yanıtlamayın.` : `This email was sent automatically. Please do not reply.`;
return ( <Html lang={locale}> <Head /> <Tailwind> <Body className="bg-[#111111] font-sans m-0 p-0"> <Container className="mx-auto py-8 px-4 max-w-[560px]"> {/* Header */} <Section className="text-center mb-6"> <table cellPadding={0} cellSpacing={0} role="presentation" style={{ margin: '0 auto' }}> <tr> <td style={{ width: '40px', height: '40px', backgroundColor: 'rgba(239, 68, 68, 0.15)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px', textAlign: 'center', verticalAlign: 'middle', paddingRight: '12px', }}> <Text className="text-[#ef4444] text-lg font-bold m-0">⚡</Text> </td> <td style={{ paddingLeft: '12px' }}> <Text className="text-white/90 text-lg font-bold tracking-[0.15em] uppercase m-0"> ECU TUNING <span style={{ color: '#ef4444' }}>PORTAL</span> </Text> </td> </tr> </table> </Section>
{/* Content */} <Section className="bg-[#1a1a1a] rounded-xl border border-white/10 p-8"> {children} </Section>
{/* Footer */} <Section className="text-center mt-6"> <Hr className="border-white/10 my-4" /> <Text className="text-white/30 text-xs m-0 mb-1"> © {year} ECU Tuning Portal </Text> <Text className="text-white/20 text-[11px] m-0"> {footerText} </Text> </Section> </Container> </Body> </Tailwind> </Html> );}- Step 2: Create the magic link template
Create lib/mail/templates/magic-link.tsx:
import { Text, Button, Hr } from '@react-email/components';import { BaseLayout } from './base-layout';import { getTranslations, interpolate } from '../i18n';
export interface MagicLinkEmailProps { name: string; magicLink: string; ipAddress: string; locale?: string;}
export function MagicLinkEmail({ name, magicLink, ipAddress, locale = 'en' }: MagicLinkEmailProps) { const t = getTranslations('magic-link', locale);
return ( <BaseLayout preview={t.subject} locale={locale}> <Text className="text-white/90 text-base m-0 mb-4"> {interpolate(t.greeting, { name })} </Text>
<Text className="text-white/60 text-sm leading-6 m-0 mb-6"> {t.body} </Text>
<Button href={magicLink} className="bg-[#ef4444] text-white text-sm font-semibold tracking-wider uppercase no-underline text-center block rounded-lg py-3 px-8 my-4" > {t.button} → </Button>
<Text className="text-white/30 text-xs m-0 mt-6 mb-2"> {t.expiry_note} </Text>
<Hr className="border-white/10 my-4" />
<Text className="text-white/40 text-xs m-0 mb-2"> {t.warning} </Text>
<Text className="text-white/25 text-[11px] m-0"> {interpolate(t.ip_notice, { ipAddress })} </Text> </BaseLayout> );}
MagicLinkEmail.PreviewProps = { name: 'Yiğit', magicLink: 'https://ecutuningportal.com/api/admin/auth/magic-link/verify?token=abc123', ipAddress: '192.168.1.1', locale: 'tr',} satisfies MagicLinkEmailProps;- Step 3: Create the template registry
Create lib/mail/templates/index.ts:
import { render } from '@react-email/components';import { MagicLinkEmail, type MagicLinkEmailProps } from './magic-link';import { getTranslations } from '../i18n';
/** * Template registry — maps template name to render function. * Adding a new template: * 1. Create the .tsx component * 2. Add an entry here with its props type and render logic */
interface TemplateResult { html: string; text: string; subject: string;}
type TemplateMap = { 'magic-link': MagicLinkEmailProps;};
export type TemplateName = keyof TemplateMap;
export async function renderTemplate<T extends TemplateName>( name: T, props: TemplateMap[T],): Promise<TemplateResult> { const locale = props.locale ?? 'en'; const t = getTranslations(name, locale);
switch (name) { case 'magic-link': { const p = props as MagicLinkEmailProps; const element = MagicLinkEmail(p); const html = await render(element); const text = await render(element, { plainText: true }); return { html, text, subject: t.subject }; } default: { const _exhaustive: never = name; throw new Error(`Unknown template: ${_exhaustive}`); } }}- Step 4: Commit
git add lib/mail/templates/git commit -m "feat(mail): React Email template'ler — base layout + magic link + registry"Task 6: Create Mail Service (Orchestrator)
Bölüm başlığı “Task 6: Create Mail Service (Orchestrator)”Files:
-
Create:
lib/mail/service.ts -
Step 1: Create the mail service
Create lib/mail/service.ts:
import { getTransporter } from './transport';import { renderTemplate, type TemplateName } from './templates';
interface SendMailOptions<T extends TemplateName> { to: string; template: T; data: Parameters<typeof renderTemplate<T>>[1];}
interface SendMailResult { success: boolean; messageId?: string; error?: string;}
/** * Send a branded email using a React Email template. * * Usage: * await sendMail({ * to: 'admin@example.com', * template: 'magic-link', * data: { name: 'Yiğit', magicLink: '...', ipAddress: '...', locale: 'tr' }, * }); */export async function sendMail<T extends TemplateName>( options: SendMailOptions<T>,): Promise<SendMailResult> { try { const { html, text, subject } = await renderTemplate(options.template, options.data);
const transporter = getTransporter(); const info = await transporter.sendMail({ from: process.env.SMTP_FROM, to: options.to, subject, html, text, });
console.log(`[Mail] Sent "${options.template}" to ${options.to} — messageId: ${info.messageId}`); return { success: true, messageId: info.messageId }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[Mail] Failed to send "${options.template}" to ${options.to}:`, message); return { success: false, error: message }; }}- Step 2: Commit
git add lib/mail/service.tsgit commit -m "feat(mail): sendMail() servis katmanı — template render + SMTP gönderim"Task 7: Create Magic Link Token Manager
Bölüm başlığı “Task 7: Create Magic Link Token Manager”Files:
-
Create:
lib/mail/tokens.ts -
Step 1: Create the token manager
Create lib/mail/tokens.ts:
import crypto from 'crypto';import prisma from '@/lib/prisma';
const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
interface GenerateTokenResult { token: string; expiresAt: Date;}
/** * Generate a magic link token for an email address. * Deletes any existing unused tokens for the same email first. */export async function generateMagicToken( email: string, meta?: { ipAddress?: string; userAgent?: string },): Promise<GenerateTokenResult> { // Clean up: delete existing unused tokens for this email await prisma.magicLinkToken.deleteMany({ where: { email, usedAt: null, }, });
const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS);
await prisma.magicLinkToken.create({ data: { email, token, expiresAt, ipAddress: meta?.ipAddress ?? null, userAgent: meta?.userAgent ?? null, }, });
return { token, expiresAt };}
interface VerifyTokenResult { valid: true; email: string;}
interface VerifyTokenError { valid: false; reason: 'not_found' | 'expired' | 'already_used';}
/** * Verify and consume a magic link token. * Returns the associated email if valid, or an error reason. * Marks the token as used (single-use). */export async function verifyMagicToken( token: string,): Promise<VerifyTokenResult | VerifyTokenError> { const record = await prisma.magicLinkToken.findUnique({ where: { token }, });
if (!record) { return { valid: false, reason: 'not_found' }; }
if (record.usedAt) { return { valid: false, reason: 'already_used' }; }
if (record.expiresAt < new Date()) { return { valid: false, reason: 'expired' }; }
// Mark as used — single-use enforcement await prisma.magicLinkToken.update({ where: { id: record.id }, data: { usedAt: new Date() }, });
return { valid: true, email: record.email };}- Step 2: Commit
git add lib/mail/tokens.tsgit commit -m "feat(mail): Magic link token yöneticisi — üretme, doğrulama, tek kullanım"Task 8: Create Magic Link Send API Route
Bölüm başlığı “Task 8: Create Magic Link Send API Route”Files:
-
Create:
app/api/admin/auth/magic-link/send/route.ts -
Step 1: Create the send route
Create app/api/admin/auth/magic-link/send/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { rateLimit } from '@/lib/rate-limit';import { auditLog } from '@/lib/audit-log';import { generateMagicToken } from '@/lib/mail/tokens';import { sendMail } from '@/lib/mail/service';
export async function POST(req: NextRequest) { const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; const ua = req.headers.get('user-agent') || '';
// Rate limit per IP: 10/hour const ipRl = rateLimit(`magic-link-ip:${ip}`, { maxRequests: 10, windowMs: 60 * 60 * 1000 }); if (!ipRl.allowed) { auditLog({ action: 'magic_link_blocked', details: 'IP rate limited', ipAddress: ip, userAgent: ua, status: 'blocked' }); return NextResponse.json( { error: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.' }, { status: 429 }, ); }
let body: { email?: string }; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Geçersiz istek.' }, { status: 400 }); }
const email = body.email?.trim().toLowerCase(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return NextResponse.json({ error: 'Geçerli bir e-posta adresi girin.' }, { status: 400 }); }
// Rate limit per email: 5/hour const emailRl = rateLimit(`magic-link-email:${email}`, { maxRequests: 5, windowMs: 60 * 60 * 1000 }); if (!emailRl.allowed) { // Still return success to prevent enumeration return NextResponse.json({ success: true }); }
// Look up admin user const admin = await prisma.adminUser.findUnique({ where: { email }, select: { id: true, name: true, email: true }, });
if (!admin) { // Email enumeration protection — same response as success auditLog({ action: 'magic_link_no_user', target: `email:${email}`, ipAddress: ip, userAgent: ua, status: 'failed' }); return NextResponse.json({ success: true }); }
// Generate token const { token } = await generateMagicToken(email, { ipAddress: ip, userAgent: ua });
// Build magic link URL const baseUrl = process.env.MAGIC_LINK_BASE_URL || 'https://ecutuningportal.com'; const magicLink = `${baseUrl}/api/admin/auth/magic-link/verify?token=${token}`;
// Send email const result = await sendMail({ to: email, template: 'magic-link', data: { name: admin.name || email, magicLink, ipAddress: ip, locale: 'tr', // Admin panel default }, });
if (!result.success) { console.error(`[MagicLink] Failed to send email to ${email}:`, result.error); auditLog({ adminId: admin.id, action: 'magic_link_send_failed', details: result.error, ipAddress: ip, userAgent: ua, status: 'failed' }); return NextResponse.json({ error: 'E-posta gönderilemedi. Lütfen tekrar deneyin.' }, { status: 500 }); }
auditLog({ adminId: admin.id, action: 'magic_link_sent', ipAddress: ip, userAgent: ua });
return NextResponse.json({ success: true });}- Step 2: Commit
git add app/api/admin/auth/magic-link/send/route.tsgit commit -m "feat(auth): Magic link gönderim API — rate limit, enumeration koruması"Task 9: Create Magic Link Verify API Route
Bölüm başlığı “Task 9: Create Magic Link Verify API Route”Files:
- Create:
app/api/admin/auth/magic-link/verify/route.ts
Context: Admin auth uses iron-session (via getAdminSession() from lib/admin-session.ts) + AdminSession DB records. The verify route must follow the exact same session creation pattern as app/api/admin/auth/login/route.ts.
- Step 1: Create the verify route
Create app/api/admin/auth/magic-link/verify/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { getAdminSession } from '@/lib/admin-session';import { auditLog, parseDevice } from '@/lib/audit-log';import { verifyMagicToken } from '@/lib/mail/tokens';
export async function GET(req: NextRequest) { const token = req.nextUrl.searchParams.get('token'); const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; const ua = req.headers.get('user-agent') || ''; const device = parseDevice(ua);
if (!token) { return NextResponse.redirect(new URL('/admin/login?error=invalid_link', req.url)); }
// Verify token const result = await verifyMagicToken(token);
if (!result.valid) { const errorMap = { not_found: 'invalid_link', expired: 'link_expired', already_used: 'link_used', } as const;
auditLog({ action: 'magic_link_verify_failed', details: `Reason: ${result.reason}`, ipAddress: ip, userAgent: ua, status: 'failed', });
return NextResponse.redirect(new URL(`/admin/login?error=${errorMap[result.reason]}`, req.url)); }
// Find admin user const admin = await prisma.adminUser.findUnique({ where: { email: result.email }, select: { id: true, email: true, name: true, role: true, totpEnabled: true, }, });
if (!admin) { return NextResponse.redirect(new URL('/admin/login?error=invalid_link', req.url)); }
// Clear 2FA verification on new login (same as password login) if (admin.totpEnabled) { await prisma.adminUser.update({ where: { id: admin.id }, data: { twoFactorVerifiedAt: null }, }); }
// Create DB session record (same pattern as login/route.ts) const dbSession = await prisma.adminSession.create({ data: { adminId: admin.id, sessionToken: `${Date.now()}-${crypto.randomUUID()}`, ipAddress: ip, userAgent: ua, device, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }, });
// Set iron-session cookie (same fields as login/route.ts) const session = await getAdminSession(); session.adminId = admin.id; session.email = admin.email; session.name = admin.name; session.role = admin.role; session.twoFactorVerified = false; session.dbSessionId = dbSession.id; await session.save();
// Audit log auditLog({ adminId: admin.id, action: 'magic_link_login', details: `2FA: ${admin.totpEnabled}, Device: ${device}`, ipAddress: ip, userAgent: ua, });
// Redirect based on 2FA status if (admin.totpEnabled) { return NextResponse.redirect(new URL('/admin/2fa-verify', req.url)); }
return NextResponse.redirect(new URL('/admin/dashboard', req.url));}- Step 2: Commit
git add app/api/admin/auth/magic-link/verify/route.tsgit commit -m "feat(auth): Magic link doğrulama API — session oluşturma, 2FA desteği"Task 10: Update Admin Login Page UI
Bölüm başlığı “Task 10: Update Admin Login Page UI”Files:
- Modify:
app/[locale]/admin/login/page.tsx
Context: Current admin login page is at app/[locale]/admin/login/page.tsx. It uses React state (useState), fetch to /api/admin/auth/login, and Lucide icons. Dark theme with bg-[#030304], glass card with bg-white/2 backdrop-blur-xl border-white/6, red accent buttons.
- Step 1: Rewrite the login page with hybrid magic link + password form
Replace the entire content of app/[locale]/admin/login/page.tsx with:
'use client';
import { useState } from 'react';import { Lock, Mail, Key, Eye, EyeOff, ArrowLeft, Send } from 'lucide-react';
type LoginMode = 'password' | 'magic-link';
export default function AdminLogin() { const [mode, setMode] = useState<LoginMode>('password'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [magicLinkSent, setMagicLinkSent] = useState(false);
// Check URL params for magic link errors const urlError = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('error') : null;
const errorMessages: Record<string, string> = { invalid_link: 'Geçersiz veya bulunamayan giriş linki.', link_expired: 'Giriş linki süresi dolmuş. Lütfen yeni bir link talep edin.', link_used: 'Bu giriş linki zaten kullanılmış. Lütfen yeni bir link talep edin.', };
const displayError = error || (urlError ? errorMessages[urlError] || 'Bir hata oluştu.' : '');
// ─── Password Login ────────────────────────────────── const handlePasswordLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError('');
try { const res = await fetch('/api/admin/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await res.json();
if (!res.ok) { setError(data.error || 'Giriş başarısız.'); return; } if (data.requires2FA) { window.location.href = '/admin/2fa-verify'; return; } if (data.success) { window.location.href = '/admin/dashboard'; } else { setError(data.error || 'Giriş başarısız.'); } } catch { setError('Bir hata oluştu. Lütfen tekrar deneyin.'); } finally { setLoading(false); } };
// ─── Magic Link ────────────────────────────────────── const handleMagicLink = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError('');
try { const res = await fetch('/api/admin/auth/magic-link/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const data = await res.json();
if (!res.ok) { setError(data.error || 'E-posta gönderilemedi.'); return; } setMagicLinkSent(true); } catch { setError('Bir hata oluştu. Lütfen tekrar deneyin.'); } finally { setLoading(false); } };
return ( <div className="min-h-screen flex items-center justify-center bg-[#030304] px-4 relative overflow-hidden"> {/* Background effects */} <div className="fixed inset-0 opacity-[0.025] pointer-events-none" style={{ backgroundImage: `linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)`, backgroundSize: '60px 60px', }} /> <div className="fixed top-1/4 right-1/4 w-[600px] h-[600px] bg-red-500/4 rounded-full blur-[200px] pointer-events-none" />
<div className="relative max-w-md w-full"> <div className="bg-white/2 backdrop-blur-xl border border-white/6 rounded-[10px] p-8 space-y-8"> {/* Header */} <div className="text-center"> <div className="mx-auto h-14 w-14 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center justify-center mb-5"> <Lock className="h-6 w-6 text-red-400" /> </div> <h2 className="text-white font-tech uppercase tracking-widest text-xl"> Yönetici Girişi </h2> <p className="mt-2 text-xs text-white/35"> Panel erişimi için bilgilerinizi girin </p> </div>
{/* Error display */} {displayError && ( <div className="text-red-400 text-sm text-center bg-red-500/8 border border-red-500/20 p-2 rounded-lg"> {displayError} </div> )}
{/* ─── Magic Link Sent State ─── */} {magicLinkSent ? ( <div className="space-y-4 text-center"> <div className="mx-auto h-14 w-14 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center justify-center"> <Mail className="h-6 w-6 text-green-400" /> </div> <p className="text-white/80 text-sm"> Giriş linki <strong className="text-white">{email}</strong> adresine gönderildi. </p> <p className="text-white/40 text-xs"> E-postanızı kontrol edin. Link 15 dakika geçerlidir. </p> <button type="button" onClick={() => { setMagicLinkSent(false); setError(''); }} className="text-red-400 text-xs hover:text-red-300 transition-colors inline-flex items-center gap-1" > <ArrowLeft className="h-3 w-3" /> Geri dön </button> </div> ) : mode === 'password' ? ( /* ─── Password Form ─── */ <> <form className="space-y-4" onSubmit={handlePasswordLogin}> <div className="relative"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <Mail className="h-4 w-4 text-white/25" /> </div> <input type="email" name="email" id="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} className="w-full pl-10 pr-4 py-3 rounded-lg border border-white/8 bg-white/3 text-white/80 placeholder:text-white/20 focus:ring-1 focus:ring-red-500/40 focus:border-red-500/40 outline-hidden transition-all" placeholder="E-posta Adresi" /> </div>
<div className="relative"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <Key className="h-4 w-4 text-white/25" /> </div> <input type={showPassword ? 'text' : 'password'} name="password" id="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} className="w-full pl-10 pr-12 py-3 rounded-lg border border-white/8 bg-white/3 text-white/80 placeholder:text-white/20 focus:ring-1 focus:ring-red-500/40 focus:border-red-500/40 outline-hidden transition-all" placeholder="Kullanıcı Şifresi" /> <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 right-0 pr-3 flex items-center text-white/25 hover:text-white/50 transition-colors" tabIndex={-1} > {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} </button> </div>
<button type="submit" aria-disabled={loading} disabled={loading} className="w-full flex justify-center py-3 px-4 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 focus:outline-hidden focus:ring-1 focus:ring-red-500/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > {loading ? 'Giriş Yapılıyor...' : 'Giriş Yap'} </button> </form>
{/* Divider */} <div className="flex items-center gap-3"> <div className="flex-1 h-px bg-white/8" /> <span className="text-white/25 text-xs uppercase tracking-wider">veya</span> <div className="flex-1 h-px bg-white/8" /> </div>
{/* Magic Link toggle */} <button type="button" onClick={() => { setMode('magic-link'); setError(''); }} className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-sm font-medium text-white/60 bg-white/3 border border-white/8 hover:bg-white/5 hover:text-white/80 hover:border-white/12 transition-all" > <Send className="h-4 w-4" /> E-posta ile Giriş Yap </button> </> ) : ( /* ─── Magic Link Form ─── */ <> <form className="space-y-4" onSubmit={handleMagicLink}> <p className="text-white/50 text-xs text-center"> E-posta adresinize tek kullanımlık giriş linki göndereceğiz. </p>
<div className="relative"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <Mail className="h-4 w-4 text-white/25" /> </div> <input type="email" name="magic-email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="w-full pl-10 pr-4 py-3 rounded-lg border border-white/8 bg-white/3 text-white/80 placeholder:text-white/20 focus:ring-1 focus:ring-red-500/40 focus:border-red-500/40 outline-hidden transition-all" placeholder="E-posta Adresi" /> </div>
<button type="submit" disabled={loading} className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 focus:outline-hidden focus:ring-1 focus:ring-red-500/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > <Send className="h-4 w-4" /> {loading ? 'Gönderiliyor...' : 'Giriş Linki Gönder'} </button> </form>
{/* Back to password */} <button type="button" onClick={() => { setMode('password'); setError(''); }} className="w-full text-center text-white/40 text-xs hover:text-white/60 transition-colors inline-flex items-center justify-center gap-1" > <ArrowLeft className="h-3 w-3" /> Şifre ile giriş yap </button> </> )} </div> </div> </div> );}- Step 2: Commit
git add app/[locale]/admin/login/page.tsxgit commit -m "feat(ui): Admin login — hibrit şifre + magic link giriş arayüzü"Task 11: Build Verification & Test Email
Bölüm başlığı “Task 11: Build Verification & Test Email”- Step 1: Run TypeScript compilation check
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx next build 2>&1 | tail -40Expected: Build succeeds with no errors related to mail service or magic link files.
- Step 2: Send a test email to verify SMTP works
Create a temporary test script and run it:
cd /var/www/vhosts/ecutuningportal.com/httpdocsnode -e "const nodemailer = require('nodemailer');const t = nodemailer.createTransport({ host: 'ecutuningportal.com', port: 465, secure: true, auth: { user: 'no-reply@ecutuningportal.com', pass: 'zbUU460s2lgUFs5K' }, tls: { rejectUnauthorized: false }});t.sendMail({ from: 'ECU Tuning Portal <no-reply@ecutuningportal.com>', to: 'ercanyter@gmail.com', subject: 'SMTP Test - ECU Tuning Portal', text: 'SMTP connection is working!'}).then(info => { console.log('SUCCESS:', info.messageId);}).catch(err => { console.error('FAILED:', err.message);});"Expected: SUCCESS: <message-id> and email arrives at ercanyter@gmail.com.
- Step 3: Test the magic link send endpoint
curl -X POST https://ecutuningportal.com/api/admin/auth/magic-link/send \ -H "Content-Type: application/json" \ -d '{"email":"<admin-email-here>"}'Expected: {"success":true} response and magic link email arrives.
- Step 4: Verify magic link works end-to-end
Click the magic link in the received email. Expected behavior:
-
If 2FA enabled → redirected to
/admin/2fa-verify -
If no 2FA → redirected to
/admin/dashboard -
Click the same link again → redirected to
/admin/login?error=link_used -
Step 5: Restart application
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx next build && touch tmp/restart.txt- Step 6: Final commit
git add -Agit commit -m "feat: Mail servisi ve magic link auth — tam entegrasyon"