From 437698f019f656682acd1ff17f121acde8fc5615 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:26:13 +0330 Subject: [PATCH] Import source: app/teknav-auth Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- app/teknav-auth.jsx | 988 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 988 insertions(+) create mode 100644 app/teknav-auth.jsx diff --git a/app/teknav-auth.jsx b/app/teknav-auth.jsx new file mode 100644 index 0000000..ec69436 --- /dev/null +++ b/app/teknav-auth.jsx @@ -0,0 +1,988 @@ +// teknav-auth.jsx — Login / Signup page +import { useState, useEffect } from 'react'; +import { useAuth, useNav, useToast } from './teknav-ui.jsx'; +import { ApiError } from './src/lib/api.js'; +import { api } from './src/lib/api.js'; +import TeknavCAP from './src/lib/TeknavCAP.jsx'; + +// ── Hooks ───────────────────────────────────────────────────────────────────── +function useTimeGreeting() { + const h = new Date().getHours(); + if (h < 5) return 'شب بخیر'; + if (h < 12) return 'صبح بخیر'; + if (h < 17) return 'روز بخیر'; + if (h < 21) return 'عصر بخیر'; + return 'شب بخیر'; +} + +function useCountUp(target, delayMs = 0) { + const [count, setCount] = useState(0); + useEffect(() => { + const t = setTimeout(() => { + const steps = 36; + let step = 0; + const iv = setInterval(() => { + step++; + const ease = 1 - Math.pow(1 - step / steps, 3); + setCount(Math.floor(ease * target)); + if (step >= steps) { setCount(target); clearInterval(iv); } + }, 1600 / steps); + return () => clearInterval(iv); + }, delayMs); + return () => clearTimeout(t); + }, [target, delayMs]); + return count; +} + +// ── Page-scoped CSS ─────────────────────────────────────────────────────────── +function AuthStyles() { + return ( + + ); +} + +// ── Icons ───────────────────────────────────────────────────────────────────── +function EyeIcon({ open }) { + return open ? ( + + + + + ) : ( + + + + + ); +} + +function GoogleIcon() { + return ( + + + + + + + ); +} + +function GithubIcon() { + return ( + + + + ); +} + +// ── Brand Panel ─────────────────────────────────────────────────────────────── +const TICKER_TOPICS = [ + 'هوش مصنوعی و آینده توسعه نرم‌افزار', + 'امنیت سایبری در دوران پس از کوانتوم', + 'پردازش ابری بومی‌سازی‌شده در ایران', + 'متن‌باز در اکوسیستم فارسی‌زبان', + 'زیرساخت‌های توزیع‌شده مقیاس‌پذیر', + 'یادگیری عمیق با مجموعه‌داده‌های فارسی', + 'معماری میکروسرویس در محیط تولید', + 'مدیریت داده در مقیاس پتابایت', +]; + +const BRAND_WORDS = [ + { text: 'هوش مصنوعی', s: 12, x: 6, y: 12, spd: 3.2 }, + { text: 'ابر', s: 10, x: 72, y: 22, spd: 4.1 }, + { text: 'امنیت', s: 11, x: 18, y: 74, spd: 3.6 }, + { text: 'متن‌باز', s: 10, x: 62, y: 84, spd: 4.5 }, + { text: 'پردازش', s: 11, x: 4, y: 48, spd: 3.9 }, + { text: 'شبکه', s: 10, x: 76, y: 58, spd: 3.3 }, + { text: 'داده', s: 12, x: 38, y: 8, spd: 4.8 }, + { text: 'زیرساخت', s: 10, x: 48, y: 91, spd: 3.7 }, +]; + +function BrandTicker() { + const doubled = [...TICKER_TOPICS, ...TICKER_TOPICS]; + return ( +
+
+ {doubled.map((item, i) => ( +
+ ◉ + {item} +
+ ))} +
+
+ ); +} + +function BrandPanel() { + const [mouse, setMouse] = useState({ x: 0, y: 0 }); + const articles = useCountUp(23, 700); + const readers = useCountUp(8400, 1000); + const series = useCountUp(6, 1300); + + useEffect(() => { + const h = (e) => setMouse({ + x: e.clientX / window.innerWidth - 0.5, + y: e.clientY / window.innerHeight - 0.5, + }); + window.addEventListener('mousemove', h, { passive: true }); + return () => window.removeEventListener('mousemove', h); + }, []); + + return ( +
+ {/* Giant watermark letter */} +
ت
+ + {/* Dot grid */} +
+ + {/* Parallax glow — follows cursor */} +
+ + {/* Terracotta accent glow */} +
+ + {/* Floating word bubbles — subtle parallax */} +
+ {BRAND_WORDS.map((w, i) => ( +
{w.text}
+ ))} +
+ + {/* ── Content ── */} +
+ + {/* Orbital logo */} +
+ {/* Outer dashed ring */} +
+ {/* Middle ring + orbiting dot */} +
+
+
+ {/* Inner ring + dot */} +
+
+
+ {/* Logo square */} +
+ ت +
+
+ + {/* Wordmark */} +
تکناو
+
TEKNAV · IR
+ + {/* Animated divider */} +
+ + {/* Tagline */} +

+ آخرین دانش فناوری،{' '} + اول به فارسی +

+ + {/* Live stats — count-up */} +
+ {[ + { n: articles, label: 'مقاله', delay: '0s', suffix: '+' }, + { n: readers, label: 'خواننده', delay: '0.18s', suffix: '' }, + { n: series, label: 'سری', delay: '0.34s', suffix: '' }, + ].map(({ n, label, delay, suffix }) => ( +
+
+ {n.toLocaleString('fa-IR')}{suffix} +
+
{label}
+
+ ))} +
+ + {/* Divider + ticker */} +
+
موضوعات داغ
+ +
+ + {/* Online pulse indicator */} +
+ + در حال حاضر آنلاین +
+
+
+ ); +} + +// ── AuthInput ───────────────────────────────────────────────────────────────── +function AuthInput({ label, hint, error, suffix, inputProps = {} }) { + const { style: inputStyle, ...rest } = inputProps; + return ( +
+ {label && ( + + )} +
+ + {suffix && ( +
{suffix}
+ )} +
+ {hint && !error &&
{hint}
} + {error &&
{error}
} +
+ ); +} + +// ── Password strength ───────────────────────────────────────────────────────── +function PasswordStrength({ pw }) { + const checks = [ + { label: '۸+ کاراکتر', ok: pw.length >= 8 }, + { label: 'حرف کوچک', ok: /[a-z]/.test(pw) }, + { label: 'حرف بزرگ', ok: /[A-Z]/.test(pw) }, + { label: 'عدد', ok: /[0-9]/.test(pw) }, + { label: 'نماد', ok: /[^a-zA-Z0-9]/.test(pw) }, + ]; + const score = checks.filter(c => c.ok).length; + const barColor = score <= 2 ? '#C94C4C' : score === 3 ? '#D08A22' : '#2F8F6B'; + return ( +
+
+ {[1,2,3,4,5].map(i => ( +
+ ))} +
+
+ {checks.map(({ label, ok }) => ( + {ok ? '✓' : '·'} {label} + ))} +
+
+ ); +} + +// ── Forgot password modal ───────────────────────────────────────────────────── +function ForgotPasswordModal({ onClose }) { + const toast = useToast(); + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + + const send = async (e) => { + e.preventDefault(); + if (!email.trim()) { toast('ایمیل را وارد کنید', 'error'); return; } + setLoading(true); + try { + await api.post('/api/auth/forgot-password', { email: email.trim().toLowerCase() }); + setSent(true); + } catch { + toast('خطا در ارسال ایمیل. لطفاً دوباره تلاش کنید', 'error'); + } finally { setLoading(false); } + }; + + return ( +
+
+
+

بازیابی رمز عبور

+

+ {sent + ? 'اگر این ایمیل در سیستم ثبت شده باشد، لینک بازنشانی برای شما ارسال خواهد شد.' + : 'آدرس ایمیل ثبت‌شده خود را وارد کنید.'} +

+ {sent ? ( + + ) : ( +
+ setEmail(e.target.value), + placeholder: 'you@example.com', autoComplete: 'email', + style: { direction: 'ltr', textAlign: 'left' }, + }} + /> +
+ + +
+ + )} +
+
+ ); +} + +// ── Login form ──────────────────────────────────────────────────────────────── +function LoginForm({ onSuccess }) { + const { login } = useAuth(); + const toast = useToast(); + const [identifier, setIdentifier] = useState(''); + const [password, setPassword] = useState(''); + const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' }); + const [loading, setLoading] = useState(false); + const [showPass, setShowPass] = useState(false); + const [showForgot, setShowForgot] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!identifier || !password) { toast('نام کاربری/ایمیل و رمز عبور الزامی است', 'error'); return; } + if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('کد امنیتی را وارد کنید', 'error'); return; } + setLoading(true); + try { + const user = await login(identifier, password, captcha.captchaId, captcha.userSolution); + if (user) onSuccess(user); + else toast('ایمیل یا رمز عبور نادرست است', 'error'); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 429) toast('تعداد تلاش‌های ناموفق از حد مجاز گذشت. چند دقیقه صبر کنید', 'error'); + else if (err.status === 401) toast('نام کاربری، ایمیل یا رمز عبور نادرست است', 'error'); + else if (err.status === 403) toast('این حساب غیرفعال است', 'error'); + else if (err.status === 400) toast('کد امنیتی نامعتبر است — کد جدید دریافت کنید', 'error'); + else toast('خطا در اتصال به سرور', 'error'); + } else toast('خطای غیرمنتظره', 'error'); + } finally { setLoading(false); } + }; + + return ( +
+ setIdentifier(e.target.value), + placeholder: '@username یا you@example.com', autoComplete: 'username', + style: { direction: 'ltr', textAlign: 'left' }, + }} + /> +
+
+ + +
+ setPassword(e.target.value), + placeholder: '••••••••', autoComplete: 'current-password', + style: { direction: 'ltr', textAlign: 'left' }, + }} + suffix={ + + } + /> +
+ + + {showForgot && setShowForgot(false)} />} + + ); +} + +// ── Signup form ─────────────────────────────────────────────────────────────── +function SignupForm({ onSuccess }) { + const { signup } = useAuth(); + const toast = useToast(); + const [name, setName] = useState(''); + const [username, setUsername] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [identifierType, setIdentifierType] = useState('email'); + const [password, setPassword] = useState(''); + const [confirmPw, setConfirmPw] = useState(''); + const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' }); + const [loading, setLoading] = useState(false); + const [showPass, setShowPass] = useState(false); + + const isPhone = identifierType === 'phone'; + const pwMatch = confirmPw && password === confirmPw; + const pwMismatch = confirmPw && password !== confirmPw; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!name.trim()) { toast('نام الزامی است', 'error'); return; } + if (!/^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/.test(username.trim().toLowerCase().replace(/^@/, ''))) { + toast('نام کاربری معتبر وارد کنید', 'error'); return; + } + if (!identifier.trim()) { toast(isPhone ? 'شماره موبایل الزامی است' : 'ایمیل الزامی است', 'error'); return; } + if (password.length < 8) { toast('رمز عبور باید حداقل ۸ کاراکتر باشد', 'error'); return; } + if (password !== confirmPw) { toast('رمزهای عبور تطابق ندارند', 'error'); return; } + if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('کد امنیتی را وارد کنید', 'error'); return; } + setLoading(true); + try { + const payload = { + name: name.trim(), + username: username.trim().toLowerCase().replace(/^@/, ''), + password, captchaId: captcha.captchaId, userSolution: captcha.userSolution, + ...(isPhone ? { phone: identifier.trim() } : { email: identifier.trim().toLowerCase() }), + }; + const user = await signup(payload); + if (user) onSuccess(user); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 409) { + const msg = err.body?.error; + toast(msg === 'username_taken' ? 'این نام کاربری قبلاً گرفته شده است' : isPhone ? 'این شماره قبلاً ثبت شده است' : 'این ایمیل قبلاً ثبت شده است', 'error'); + } else if (err.status === 429) { + toast('تعداد ثبت‌نام از این آی‌پی زیاد است. کمی صبر کنید', 'error'); + } else if (err.status === 400) { + toast(err.body?.error === 'invalid_captcha' ? 'کد امنیتی نامعتبر است' : 'اطلاعات وارد‌شده معتبر نیست', 'error'); + } else toast('خطا در اتصال به سرور', 'error'); + } else toast('خطای غیرمنتظره', 'error'); + } finally { setLoading(false); } + }; + + return ( +
+ setName(e.target.value), + placeholder: 'مثال: علی رضایی', autoComplete: 'name', + }} + /> + setUsername(e.target.value.toLowerCase().replace(/^@/, '')), + placeholder: 'ali_rezaei', autoComplete: 'username', + style: { direction: 'ltr', textAlign: 'left' }, + }} + /> +
+
+ {[{ key: 'email', label: 'ایمیل' }, { key: 'phone', label: 'موبایل' }].map(t => ( + + ))} +
+ setIdentifier(e.target.value), + placeholder: isPhone ? '09123456789' : 'you@example.com', + autoComplete: isPhone ? 'tel' : 'email', + style: { direction: 'ltr', textAlign: 'left' }, + }} + /> +
+
+ + setPassword(e.target.value), + placeholder: '••••••••', autoComplete: 'new-password', + style: { direction: 'ltr', textAlign: 'left' }, + }} + suffix={ + + } + /> + {password && } +
+ + + + ) : null} + inputProps={{ + type: 'password', value: confirmPw, + onChange: e => setConfirmPw(e.target.value), + placeholder: '••••••••', autoComplete: 'new-password', + style: { direction: 'ltr', textAlign: 'left' }, + }} + /> + +

+ با ثبت‌نام،{' '} + شرایط استفاده + {' '}و{' '} + سیاست حریم خصوصی + {' '}تکناو را می‌پذیرید. +

+ + + ); +} + +// ── OAuth buttons ───────────────────────────────────────────────────────────── +function OAuthButtons({ oauthError }) { + const { startOAuth } = useAuth(); + const [providers, setProviders] = useState([]); + useEffect(() => { + api.get('/api/auth/oauth/providers') + .then(d => setProviders(d?.providers ?? [])) + .catch(() => {}); + }, []); + if (providers.length === 0 && !oauthError) return null; + return ( +
+ {oauthError && ( +
+ {oauthError === 'account_suspended' ? 'این حساب معلق شده است' : 'ورود با این روش ممکن نبود. دوباره تلاش کنید'} +
+ )} + {providers.length > 0 && ( + <> +
+ {providers.includes('google') && ( + + )} + {providers.includes('github') && ( + + )} +
+
+
+ یا با ایمیل +
+
+ + )} +
+ ); +} + +// ── Main LoginPage ──────────────────────────────────────────────────────────── +function LoginPage() { + const { navigate } = useNav(); + const toast = useToast(); + const { user, refreshMe } = useAuth(); + const [tab, setTab] = useState('login'); + const greeting = useTimeGreeting(); + + const hashParams = new URLSearchParams(window.location.hash.split('?')[1] || ''); + const oauthError = hashParams.get('error') || null; + const emailVerifyToken = new URLSearchParams(window.location.search).get('verifyEmail'); + + useEffect(() => { + if (!emailVerifyToken || !user) return; + api.post('/api/auth/email/verify/confirm', { token: emailVerifyToken }) + .then(async () => { + const freshUser = await refreshMe?.(); + toast('ایمیل شما تایید شد'); + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + navigate((freshUser?.role ?? user.role) === 'reader' ? '/' : '/admin'); + }) + .catch(() => toast('تایید ایمیل ناموفق بود یا لینک منقضی شده است', 'error')); + }, [emailVerifyToken, user]); + + useEffect(() => { + if (user && !emailVerifyToken) navigate(user.role === 'reader' ? '/' : '/admin'); + }, [user, emailVerifyToken]); + + const handleSuccess = (u) => { + toast(`خوش آمدید، ${u.name}!`); + navigate(u.role === 'reader' ? '/' : '/admin'); + }; + + return ( + <> + +
+ + {/* ── Form column (right in RTL) ──────────────────────────────── */} +
+ {/* Radial vignette overlay */} +
+ +
+ + {/* Mobile logo */} +
+
+ ت +
+ تکناو +
+ + {/* Heading with vertical accent bar + time-based greeting */} +
+
+
+

+ {tab === 'login' ? greeting : 'عضو تکناو شوید'} +

+
+

+ {tab === 'login' + ? 'با حساب کاربری خود وارد شوید' + : 'در چند ثانیه حساب رایگان بسازید'} +

+
+ + {/* Pill tab switcher */} +
+ {[{ key: 'login', label: 'ورود' }, { key: 'signup', label: 'ثبت‌نام' }].map(t => ( + + ))} +
+ + {/* OAuth */} + + + {/* Form */} +
+ {tab === 'login' + ? + : } +
+ + {/* Switch prompt */} +
+ {tab === 'login' ? ( + <>حساب ندارید؟{' '} + ) : ( + <>قبلاً حساب ساخته‌اید؟{' '} + )} +
+ +
+ TEKNAV · نشریه فناوری فارسی +
+
+
+ + {/* ── Brand column (left in RTL) ──────────────────────────────── */} + +
+ + ); +} + +// ── Shared styles ───────────────────────────────────────────────────────────── +const goldBtn = (loading) => ({ + width: '100%', padding: '13px 0', borderRadius: 10, border: 'none', + background: loading ? '#E8E2D8' : 'linear-gradient(135deg, #C8951C 0%, #D49A2A 60%, #C8951C 100%)', + color: loading ? '#B0B8BA' : '#0F2A2E', + fontSize: 14, fontWeight: 700, cursor: loading ? 'not-allowed' : 'pointer', + fontFamily: 'Vazirmatn,sans-serif', + boxShadow: loading ? 'none' : '0 4px 16px rgba(200,149,28,0.22)', + transition: 'opacity 0.2s, transform 0.15s, box-shadow 0.2s', + letterSpacing: '0.02em', marginTop: 4, +}); + +const cancelBtn = { + flex: 1, padding: '12px 0', border: '1.5px solid #E4DDD2', borderRadius: 10, + background: 'transparent', color: '#7B8C8E', cursor: 'pointer', + fontSize: 13, fontWeight: 600, fontFamily: 'Vazirmatn,sans-serif', +}; + +const oauthBtnStyle = { + display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, + width: '100%', padding: '11px 0', borderRadius: 10, + border: '1.5px solid #E4DDD2', background: '#fff', cursor: 'pointer', + fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, fontWeight: 600, color: '#1A2E30', + transition: 'border-color 0.18s, background 0.18s', +}; + +export { LoginPage };