İçeriğe geç

Mail Service & Magic Link Authentication — Implementation Plan

Derin

For 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


FileActionResponsibility
lib/mail/transport.tsCreateSingleton pooled Nodemailer SMTP transporter
lib/mail/i18n.tsCreatePer-template locale string dictionaries
lib/mail/templates/base-layout.tsxCreateShared branded email layout (header, footer, colors)
lib/mail/templates/magic-link.tsxCreateMagic link email template
lib/mail/templates/index.tsCreateTemplate registry — type-safe render function
lib/mail/service.tsCreateMain sendMail() orchestrator
lib/mail/tokens.tsCreategenerateMagicToken() / verifyMagicToken() — DB-backed
prisma/schema.prismaEditAdd MagicLinkToken model
.envEditAdd SMTP + magic link env vars
app/api/admin/auth/magic-link/send/route.tsCreateAPI: generate token + send email
app/api/admin/auth/magic-link/verify/route.tsCreateAPI: verify token + create session
app/[locale]/admin/login/page.tsxEditHybrid 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

Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npm install @react-email/components nodemailer
npm 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.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=no-reply@ecutuningportal.com
SMTP_PASS=zbUU460s2lgUFs5K
SMTP_FROM=ECU Tuning Portal <no-reply@ecutuningportal.com>
# ─── Magic Link ──────────────────────────────────────────────
MAGIC_LINK_BASE_URL=https://ecutuningportal.com
  • Step 3: Commit
Terminal window
git add package.json package-lock.json .env
git commit -m "chore: React Email + Nodemailer bağımlılıkları ve SMTP yapılandırması"

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
Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx prisma migrate dev --name add_magic_link_token

Expected: Migration created and applied, MagicLinkToken table created in PostgreSQL.

  • Step 3: Verify Prisma client generation
Terminal window
npx prisma generate

Expected: Prisma client generated successfully with MagicLinkToken model accessible.

  • Step 4: Commit
Terminal window
git add prisma/
git commit -m "db: MagicLinkToken modeli — magic link auth token'ları"

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
Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx tsc --noEmit lib/mail/transport.ts 2>&1 || echo "Check for type errors"
  • Step 3: Commit
Terminal window
git add lib/mail/transport.ts
git commit -m "feat(mail): Nodemailer SMTP transport — pooled, SSL, Plesk uyumlu"

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
Terminal window
git add lib/mail/i18n.ts
git commit -m "feat(mail): i18n çeviri sözlükleri — EN + TR, genişletilebilir yapı"

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
Terminal window
git add lib/mail/templates/
git commit -m "feat(mail): React Email template'ler — base layout + magic link + registry"

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
Terminal window
git add lib/mail/service.ts
git commit -m "feat(mail): sendMail() servis katmanı — template render + SMTP gönderim"

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
Terminal window
git add lib/mail/tokens.ts
git commit -m "feat(mail): Magic link token yöneticisi — üretme, doğrulama, tek kullanım"

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
Terminal window
git add app/api/admin/auth/magic-link/send/route.ts
git commit -m "feat(auth): Magic link gönderim API — rate limit, enumeration koruması"

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
Terminal window
git add app/api/admin/auth/magic-link/verify/route.ts
git commit -m "feat(auth): Magic link doğrulama API — session oluşturma, 2FA desteği"

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
Terminal window
git add app/[locale]/admin/login/page.tsx
git commit -m "feat(ui): Admin login — hibrit şifre + magic link giriş arayüzü"

  • Step 1: Run TypeScript compilation check
Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx next build 2>&1 | tail -40

Expected: 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:

Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
node -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
Terminal window
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

Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx next build && touch tmp/restart.txt
  • Step 6: Final commit
Terminal window
git add -A
git commit -m "feat: Mail servisi ve magic link auth — tam entegrasyon"