This commit is contained in:
2025-11-29 14:49:20 +01:00
parent e951a0dd44
commit 77affbad71
19 changed files with 4664 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
services:
web:
build: ./web
ports:
- "8025:80"
environment:
- FLAG=EPFL{test_flag_placeholder}
- DOMAIN=auth.ctf.cx
- SMTP2HTTP_TOKEN=different_secret_on_remote
smtp2http:
build:
context: ./smtp2http
command: ["--webhook=http://web/api/email?token=different_secret_on_remote"]
mta:
build: ./mta
ports:
- "25:25"
- "587:25" # in case player's ISP blocks port 25

View File

@@ -0,0 +1,13 @@
FROM python:3.14-slim
RUN groupadd --system --gid 999 ctf \
&& useradd --system --gid 999 --uid 999 --create-home ctf
RUN pip install 'aiosmtpd==1.4.6'
COPY server.py /app/server.py
USER ctf
WORKDIR /app
CMD ["python", "server.py"]

View File

@@ -0,0 +1,121 @@
import asyncio
import logging
from email import message_from_bytes
from aiosmtpd.controller import Controller
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 25
FORWARD_HOST = "smtp2http"
FORWARD_PORT = 25
class MTAHandler:
async def handle_DATA(self, server, session, envelope):
try:
peer = session.peer
client_ip = peer[0] if peer else "unknown"
logger.info(f"Received email from {client_ip}: {envelope.mail_from} -> {envelope.rcpt_tos}")
message_data = envelope.content
msg = message_from_bytes(message_data)
# Security: Remove any existing Received headers to prevent spoofing
# Only the last MTA's Received header should be trusted
if 'Received' in msg:
del msg['Received']
msg['Received'] = client_ip
await self.forward_email(
msg,
envelope.mail_from,
envelope.rcpt_tos
)
return '250 OK: Message accepted for delivery'
except Exception as e:
logger.error(f"Error processing email: {e}", exc_info=True)
return '451 Requested action aborted: error in processing'
async def forward_email(self, msg, mail_from, rcpt_tos):
try:
reader, writer = await asyncio.open_connection(FORWARD_HOST, FORWARD_PORT)
# Read banner
banner = await reader.readline()
logger.debug(f"smtp2http banner: {banner.decode().strip()}")
# Send EHLO
writer.write(b"EHLO mta\r\n")
await writer.drain()
response = await reader.readline()
logger.debug(f"EHLO response: {response.decode().strip()}")
# Send MAIL FROM
writer.write(f"MAIL FROM:<{mail_from}>\r\n".encode())
await writer.drain()
response = await reader.readline()
logger.debug(f"MAIL FROM response: {response.decode().strip()}")
# Send RCPT TO for each recipient
for recipient in rcpt_tos:
writer.write(f"RCPT TO:<{recipient}>\r\n".encode())
await writer.drain()
response = await reader.readline()
logger.debug(f"RCPT TO response: {response.decode().strip()}")
# Send DATA command
writer.write(b"DATA\r\n")
await writer.drain()
response = await reader.readline()
logger.debug(f"DATA response: {response.decode().strip()}")
email_content = msg.as_string().encode('utf-8')
writer.write(email_content)
if not email_content.endswith(b"\r\n"):
writer.write(b"\r\n")
writer.write(b".\r\n")
await writer.drain()
response = await reader.readline()
logger.debug(f"Message response: {response.decode().strip()}")
# Send QUIT
writer.write(b"QUIT\r\n")
await writer.drain()
response = await reader.readline()
logger.debug(f"QUIT response: {response.decode().strip()}")
writer.close()
await writer.wait_closed()
logger.info(f"Successfully forwarded email from {mail_from} to {rcpt_tos}")
except Exception as e:
logger.error(f"Failed to forward email: {e}", exc_info=True)
async def main():
handler = MTAHandler()
controller = Controller(
handler,
hostname=LISTEN_HOST,
port=LISTEN_PORT
)
controller.start()
logger.info(f"MTA listening on {LISTEN_HOST}:{LISTEN_PORT}")
try:
await asyncio.Event().wait()
finally:
controller.stop()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,13 @@
FROM golang:1.14.4 AS builder
RUN git clone https://github.com/alash3al/smtp2http /go/src/build
WORKDIR /go/src/build
COPY diff.patch .
RUN git apply diff.patch
RUN go mod vendor
ENV CGO_ENABLED=0
RUN GOOS=linux go build -mod vendor -a -o smtp2http .
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/build/smtp2http /usr/bin/smtp2http
ENTRYPOINT ["smtp2http"]

View File

@@ -0,0 +1,49 @@
diff --git a/go.mod b/go.mod
index a12f908..0402218 100644
--- a/go.mod
+++ b/go.mod
@@ -6,5 +6,6 @@ require (
github.com/alash3al/go-smtpsrv v0.0.0-20220704173150-cdaad3f3f582 // indirect
github.com/go-resty/resty/v2 v2.3.0
github.com/miekg/dns v1.1.50 // indirect
+ github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9 // indirect
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect
)
diff --git a/main.go b/main.go
index b7b6b4f..8e37638 100644
--- a/main.go
+++ b/main.go
@@ -6,12 +6,14 @@ import (
"fmt"
"io/ioutil"
"log"
+ "net"
"net/mail"
"strings"
"time"
"github.com/alash3al/go-smtpsrv"
"github.com/go-resty/resty/v2"
+ "github.com/zaccone/spf"
)
func main() {
@@ -27,7 +29,17 @@ func main() {
return errors.New("Cannot read your message: " + err.Error())
}
- spfResult, _, _ := c.SPF()
+ // replace source IP with the one from Received header because there is an additional MTA in front
+ var spfResult spf.Result = spf.None
+ receivedAddr := msg.Header.Get("Received")
+ if receivedAddr == "" {
+ spfResult, _, _ = c.SPF()
+ } else {
+ _, host, err := smtpsrv.SplitAddress(c.From().Address)
+ if err == nil {
+ spfResult, _, _ = spf.CheckHost(net.ParseIP(receivedAddr), host, c.From().Address)
+ }
+ }
jsonData := EmailMessage{
ID: msg.MessageID,

View File

@@ -0,0 +1,11 @@
node_modules
__pycache__
*.pyc
.DS_Store
.git
.parcel-cache
dist
frontend/node_modules
frontend/.parcel-cache
frontend/dist
static/.parcel-cache

View File

@@ -0,0 +1,21 @@
FROM python:3.14-slim
RUN groupadd --system --gid 999 ctf \
&& useradd --system --gid 999 --uid 999 --create-home ctf
RUN apt-get update && apt-get install -y nodejs npm && rm -rf /var/lib/apt/lists/*
RUN pip install 'fastapi[standard]==0.121.0' 'pyjwt==2.10.1' 'fastapi-cli==0.0.14'
COPY frontend/package.json /app/frontend/package.json
WORKDIR /app/frontend
RUN npm install
COPY frontend /app/frontend
RUN npm run build
COPY backend /app/backend
USER ctf
WORKDIR /app
CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "80", "backend"]

View File

@@ -0,0 +1,293 @@
import logging
import os
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict
import jwt
from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
# Configuration
SECRET_KEY = secrets.token_urlsafe(32)
ALGORITHM = "HS256"
FLAG = os.getenv("FLAG")
DOMAIN = os.getenv("DOMAIN")
SMTP2HTTP_TOKEN = os.getenv("SMTP2HTTP_TOKEN")
if not FLAG or not DOMAIN or not SMTP2HTTP_TOKEN:
logger.error("FLAG, DOMAIN, and SMTP2HTTP_TOKEN environment variables must be set")
raise RuntimeError("Missing required environment variables")
# In-memory storage
users_db: Dict[str, str] = {} # email -> role
pending_registrations: Dict[str, WebSocket] = {} # token -> websocket
pending_logins: Dict[str, WebSocket] = {} # token -> websocket
# Initialize admin user at startup
admin_email = "admin@auth.ctf.cx"
users_db[admin_email] = "admin"
logger.info(f"Admin user created: {admin_email}")
security = HTTPBearer()
class Address(BaseModel):
address: str
class Addresses(BaseModel):
from_: Address = Field(alias="from")
to: Address
class EmailPayload(BaseModel):
subject: str
addresses: Addresses
spf: str
def create_jwt_token(email: str, role: str) -> str:
"""Create a JWT token for the user"""
payload = {
"sub": email,
"email": email,
"role": role,
"is_admin": role == "admin",
"exp": datetime.utcnow() + timedelta(hours=1)
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_jwt_token(token: str) -> dict:
"""Verify and decode a JWT token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Get the current user from JWT token"""
token = credentials.credentials
return verify_jwt_token(token)
@app.get("/")
async def root():
"""Serve the main page"""
return FileResponse(Path("/app/static/index.html"))
@app.websocket("/ws/auth")
async def websocket_auth(websocket: WebSocket):
"""WebSocket endpoint for registration and login"""
await websocket.accept()
logger.info("WebSocket connection established")
try:
while True:
data = await websocket.receive_json()
action = data.get("action")
if action == "register":
# Generate a random token
token = secrets.token_urlsafe(16)
pending_registrations[token] = websocket
# Create mailto link
mailto_link = f"mailto:magic@{DOMAIN}?subject=register:{token}"
await websocket.send_json({
"status": "pending",
"message": "Please send an email to complete registration",
"token": token,
"mailto_link": mailto_link,
"auth_email": f"magic@{DOMAIN}",
"subject": f"register:{token}"
})
logger.info(f"Registration pending, token={token}")
elif action == "login":
# Generate a random token
token = secrets.token_urlsafe(16)
pending_logins[token] = websocket
# Create mailto link
mailto_link = f"mailto:magic@{DOMAIN}?subject=login:{token}"
await websocket.send_json({
"status": "pending",
"message": "Please send an email to complete login",
"token": token,
"mailto_link": mailto_link,
"auth_email": f"magic@{DOMAIN}",
"subject": f"login:{token}"
})
logger.info(f"Login pending, token={token}")
else:
await websocket.send_json({
"status": "error",
"message": "Unknown action"
})
except WebSocketDisconnect:
logger.info("WebSocket connection closed")
# Clean up pending requests for this websocket
for token, ws in list(pending_registrations.items()):
if ws == websocket:
del pending_registrations[token]
for token, ws in list(pending_logins.items()):
if ws == websocket:
del pending_logins[token]
@app.post("/api/email")
async def email_webhook(token: str, payload: EmailPayload):
"""Webhook endpoint to receive emails from smtp2http"""
if token != SMTP2HTTP_TOKEN:
return {"status": "Invalid token"}
logger.info("Received email payload: %s", payload)
# Check SPF
if payload.spf != "pass":
logger.warning(f"Email failed SPF check: {payload.spf}")
return {"status": "Email rejected due to SPF failure"}
# Parse the subject
subject = payload.subject.strip()
# Handle registration
if subject.startswith("register:"):
token = subject.split(":", 1)[1].strip()
if token in pending_registrations:
websocket = pending_registrations[token]
email = payload.addresses.from_.address
if email in users_db:
try:
await websocket.send_json({
"status": "error",
"message": "Email already registered"
})
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
del pending_registrations[token]
return {"status": "Email already registered"}
if payload.addresses.to.address.lower() != f"magic@{DOMAIN}":
try:
await websocket.send_json({
"status": "error",
"message": f"Email must be sent to magic@{DOMAIN}"
})
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
del pending_registrations[token]
return {"status": f"Email must be sent to magic@{DOMAIN}"}
# Store user with default role
role = "user"
users_db[email] = role
# Generate JWT
jwt_token = create_jwt_token(email, role)
# Send JWT to websocket
try:
await websocket.send_json({
"status": "success",
"message": "Registration successful!",
"token": jwt_token,
"email": email,
"role": role,
"is_admin": False
})
logger.info(f"User registered: {email} -> {role}")
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
# Clean up
del pending_registrations[token]
else:
logger.warning(f"Unknown registration token: {token}")
# Handle login
elif subject.startswith("login:"):
token = subject.split(":", 1)[1].strip()
if token in pending_logins:
websocket = pending_logins[token]
email = payload.addresses.from_.address
if email not in users_db:
try:
await websocket.send_json({
"status": "error",
"message": "Email not registered"
})
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
del pending_logins[token]
return {"status": "Email not registered"}
role = users_db[email]
# Generate JWT
jwt_token = create_jwt_token(email, role)
# Send JWT to websocket
try:
await websocket.send_json({
"status": "success",
"message": "Login successful!",
"token": jwt_token,
"email": email,
"role": role,
"is_admin": role == "admin"
})
logger.info(f"User logged in: {email}")
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
# Clean up
del pending_logins[token]
else:
logger.warning(f"Unknown login token: {token}")
return {"status": "Email processed"}
@app.get("/api/flag")
async def get_flag(user: dict = Depends(get_current_user)):
"""Get the flag (only for admins)"""
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Only admins can access the flag")
return {"flag": FLAG}
@app.get("/api/me")
async def get_me(user: dict = Depends(get_current_user)):
"""Get current user info"""
return user
# Mount static files (frontend will be built here)
try:
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
except Exception as e:
logger.warning(f"Static files not mounted: {e}")

View File

@@ -0,0 +1,9 @@
{
"extends": "@parcel/config-default",
"transformers": {
"*.{js,mjs,jsx,cjs,ts,tsx}": [
"@parcel/transformer-js",
"@parcel/transformer-react-refresh-wrap"
]
}
}

View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "magic-auth-frontend",
"version": "1.0.0",
"description": "MagicAuth Frontend",
"author": "sam.ninja",
"scripts": {
"dev": "parcel src/index.html",
"build": "parcel build src/index.html --dist-dir ../static --public-url /static"
},
"dependencies": {
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@tailwindcss/postcss": "^4.1.16",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@parcel/transformer-postcss": "^2.16.1",
"autoprefixer": "^10.4.21",
"parcel": "^2.16.1",
"postcss": "^8.5.6",
"process": "^0.11.10"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect, useRef } from 'react';
import { QRCodeSVG } from 'qrcode.react';
const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [mode, setMode] = useState('login'); // 'login' or 'register'
const [status, setStatus] = useState('');
const [mailtoLink, setMailtoLink] = useState('');
const [authEmail, setAuthEmail] = useState('');
const [subject, setSubject] = useState('');
const [flag, setFlag] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const wsRef = useRef(null);
useEffect(() => {
// Check if token exists in localStorage
const savedToken = localStorage.getItem('authToken');
if (savedToken) {
setToken(savedToken);
fetchUserInfo(savedToken);
}
}, []);
const fetchUserInfo = async (authToken) => {
try {
const response = await fetch('/api/me', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
setIsAuthenticated(true);
// If admin, try to fetch flag
if (userData.is_admin) {
fetchFlag(authToken);
}
} else {
// Token is invalid
localStorage.removeItem('authToken');
setToken(null);
}
} catch (error) {
console.error('Failed to fetch user info:', error);
localStorage.removeItem('authToken');
setToken(null);
}
};
const fetchFlag = async (authToken) => {
try {
const response = await fetch('/api/flag', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (response.ok) {
const data = await response.json();
setFlag(data.flag);
}
} catch (error) {
console.error('Failed to fetch flag:', error);
}
};
const connectWebSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/auth`);
ws.onopen = () => {
console.log('WebSocket connected');
setError('');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
if (data.status === 'pending') {
setStatus(data.message);
setMailtoLink(data.mailto_link);
setAuthEmail(data.auth_email);
setSubject(data.subject);
setLoading(false);
} else if (data.status === 'success') {
setStatus(data.message);
setToken(data.token);
setUser({
sub: data.email,
email: data.email,
role: data.role,
is_admin: data.is_admin
});
setIsAuthenticated(true);
localStorage.setItem('authToken', data.token);
setLoading(false);
// Fetch flag if admin
if (data.is_admin) {
fetchFlag(data.token);
}
// Close WebSocket
ws.close();
} else if (data.status === 'error') {
setError(data.message);
setStatus('');
setMailtoLink('');
setLoading(false);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setError('WebSocket connection failed');
setLoading(false);
};
ws.onclose = () => {
console.log('WebSocket closed');
};
wsRef.current = ws;
};
const handleAuth = (authMode) => {
setError('');
setStatus('Connecting...');
setLoading(true);
// Connect WebSocket
connectWebSocket();
// Wait for connection to open
setTimeout(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: authMode
}));
}
}, 500);
};
const handleLogout = () => {
localStorage.removeItem('authToken');
setIsAuthenticated(false);
setUser(null);
setToken(null);
setFlag(null);
setStatus('');
setMailtoLink('');
setAuthEmail('');
setSubject('');
setError('');
setLoading(false);
if (wsRef.current) {
wsRef.current.close();
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
};
if (isAuthenticated && user) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-center mb-6 text-blue-600">
MagicAuth
</h1>
<div className="mb-6">
<h2 className="text-xl font-semibold mb-2">Welcome!</h2>
<p className="text-gray-600">Email: {user.email}</p>
<p className="text-gray-600">
Role: {user.is_admin ? (
<span className="text-green-600 font-semibold">Admin</span>
) : (
<span>User</span>
)}
</p>
</div>
{user.is_admin && flag && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded">
<h3 className="font-semibold text-green-800 mb-2">Flag:</h3>
<code className="text-sm text-green-900 break-all">{flag}</code>
</div>
)}
{user.is_admin && !flag && (
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-yellow-800">Loading flag...</p>
</div>
)}
<button
onClick={handleLogout}
className="w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition"
>
Logout
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-center mb-6 text-blue-600">
MagicAuth
</h1>
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-800">
No passwords or validation codes needed! Authenticate via email with our revolutionary MagicAuth system.
</p>
</div>
{!mailtoLink && !loading && (
<>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded mb-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="flex space-x-4">
<button
onClick={() => handleAuth('login')}
className="flex-1 bg-blue-500 text-white py-3 px-4 rounded hover:bg-blue-600 transition font-semibold text-lg"
>
Login
</button>
<button
onClick={() => handleAuth('register')}
className="flex-1 bg-green-500 text-white py-3 px-4 rounded hover:bg-green-600 transition font-semibold text-lg"
>
Register
</button>
</div>
</>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-8">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500 mb-4"></div>
<p className="text-gray-600">{status || 'Connecting...'}</p>
</div>
)}
{mailtoLink && !loading && (
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
{/* Left column: Automatic */}
<div className="p-4 bg-green-50 border border-green-200 rounded">
<h3 className="font-semibold text-green-800 mb-3 text-center">
Automatic
</h3>
<a
href={mailtoLink}
className="block w-full text-center bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 transition font-semibold mb-4"
>
Compose Email
</a>
<div className="flex justify-center">
<div className="bg-white p-3 rounded border-2 border-green-300">
<QRCodeSVG value={mailtoLink} size={150} />
</div>
</div>
<p className="text-xs text-green-700 text-center mt-2">
Scan with your phone
</p>
</div>
{/* Right column: Manual */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded">
<h3 className="font-semibold text-blue-800 mb-3 text-center">
Manual
</h3>
<div className="space-y-2">
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">
To:
</label>
<div className="flex items-center space-x-1">
<input
type="text"
value={authEmail}
readOnly
className="flex-1 px-2 py-1.5 bg-white border border-blue-300 rounded font-mono text-xs"
onClick={(e) => e.target.select()}
/>
<button
onClick={() => copyToClipboard(authEmail)}
className="px-2 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-xs"
>
Copy
</button>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">
Subject:
</label>
<div className="flex items-center space-x-1">
<input
type="text"
value={subject}
readOnly
className="flex-1 px-2 py-1.5 bg-white border border-blue-300 rounded font-mono text-xs"
onClick={(e) => e.target.select()}
/>
<button
onClick={() => copyToClipboard(subject)}
className="px-2 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-xs"
>
Copy
</button>
</div>
</div>
</div>
<p className="text-xs text-blue-600 mt-3">
Email body can be empty
</p>
</div>
</div>
{/* Waiting spinner below both columns */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-yellow-600"></div>
<p className="text-sm text-yellow-800">
Waiting for email verification...
</p>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default App;

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MagicAuth™</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-50;
}

View File

@@ -0,0 +1,9 @@
module.exports = {
content: [
"./src/**/*.{html,js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}