Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"!**/*.min.js",
"!**/pnpm-lock.yaml",
"!**/package-lock.json",
"!**/yarn.lock"
"!**/yarn.lock",
"!**/docs/supabase-email-templates"
]
},
"formatter": {
Expand Down
20 changes: 20 additions & 0 deletions docs/supabase-email-templates/confirm-signup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div style="max-width:520px;margin:0 auto;padding:32px 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#1e1b4b;background:#f5f3ff;border-radius:16px;">
<div style="text-align:center;margin-bottom:28px;">
<div style="display:inline-block;width:48px;height:48px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:12px;line-height:48px;font-size:22px;font-weight:700;color:#fff;">E</div>
<h1 style="margin:12px 0 4px;font-size:22px;font-weight:700;color:#1e1b4b;">EchoType</h1>
</div>
<div style="background:#fff;border-radius:12px;padding:28px 24px;border:1px solid #e0e7ff;">
<h2 style="margin:0 0 8px;font-size:18px;font-weight:600;color:#312e81;">Confirm your email</h2>
<p style="margin:0 0 20px;font-size:14px;color:#64748b;">Welcome to EchoType! Please confirm your email address to get started.</p>
<div style="background:#eef2ff;border:1px dashed #a5b4fc;border-radius:8px;padding:16px;text-align:center;margin-bottom:20px;">
<p style="margin:0 0 4px;font-size:12px;color:#6366f1;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Verification Code</p>
<p style="margin:0;font-size:32px;font-weight:700;letter-spacing:8px;color:#312e81;">{{ .Token }}</p>
</div>
<p style="margin:0 0 16px;font-size:13px;color:#94a3b8;text-align:center;">Or click the button below:</p>
<div style="text-align:center;margin-bottom:20px;">
<a href="{{ .ConfirmationURL }}" style="display:inline-block;padding:12px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Confirm Email</a>
</div>
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">If you didn't create an account, you can safely ignore this email.</p>
</div>
<p style="margin:24px 0 0;font-size:11px;color:#a5b4fc;text-align:center;">EchoType — Master English through listening, speaking, reading & writing</p>
</div>
20 changes: 20 additions & 0 deletions docs/supabase-email-templates/magic-link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div style="max-width:520px;margin:0 auto;padding:32px 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#1e1b4b;background:#f5f3ff;border-radius:16px;">
<div style="text-align:center;margin-bottom:28px;">
<div style="display:inline-block;width:48px;height:48px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:12px;line-height:48px;font-size:22px;font-weight:700;color:#fff;">E</div>
<h1 style="margin:12px 0 4px;font-size:22px;font-weight:700;color:#1e1b4b;">EchoType</h1>
</div>
<div style="background:#fff;border-radius:12px;padding:28px 24px;border:1px solid #e0e7ff;">
<h2 style="margin:0 0 8px;font-size:18px;font-weight:600;color:#312e81;">Sign in to your account</h2>
<p style="margin:0 0 20px;font-size:14px;color:#64748b;">Use the code below or click the button to sign in.</p>
<div style="background:#eef2ff;border:1px dashed #a5b4fc;border-radius:8px;padding:16px;text-align:center;margin-bottom:20px;">
<p style="margin:0 0 4px;font-size:12px;color:#6366f1;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Verification Code</p>
<p style="margin:0;font-size:32px;font-weight:700;letter-spacing:8px;color:#312e81;">{{ .Token }}</p>
</div>
<p style="margin:0 0 16px;font-size:13px;color:#94a3b8;text-align:center;">Or click the button below:</p>
<div style="text-align:center;margin-bottom:20px;">
<a href="{{ .ConfirmationURL }}" style="display:inline-block;padding:12px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Sign In</a>
</div>
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.</p>
</div>
<p style="margin:24px 0 0;font-size:11px;color:#a5b4fc;text-align:center;">EchoType — Master English through listening, speaking, reading & writing</p>
</div>
288 changes: 239 additions & 49 deletions src/app/(app)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import { Zap } from 'lucide-react';
import { ArrowLeft, ArrowRight, Loader2, Mail } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect } from 'react';
import { Suspense, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n/use-i18n';
import { useAuthStore } from '@/stores/auth-store';

function GoogleIcon({ className }: { className?: string }) {
Expand Down Expand Up @@ -48,69 +49,258 @@ export default function LoginPage() {
}

function LoginContent() {
const { signInWithGoogle, signInWithGitHub, isAuthenticated } = useAuthStore();
const {
signInWithGoogle,
signInWithGitHub,
signInWithEmail,
verifyEmailOtp,
resetEmailAuth,
isAuthenticated,
emailAuthLoading,
emailAuthError,
emailOtpSent,
pendingEmail,
} = useAuthStore();
const router = useRouter();
const searchParams = useSearchParams();
const authError = searchParams.get('auth_error');
const { t } = useI18n('login');

const [email, setEmail] = useState('');
const [otpDigits, setOtpDigits] = useState<string[]>(['', '', '', '', '', '']);
const otpInputRefs = useRef<(HTMLInputElement | null)[]>([]);

const getLocalizedError = (error: string | null): string => {
if (!error) return t('authFailed');
const lower = error.toLowerCase();
if (lower.includes('sending') && lower.includes('email')) return t('errorSendingEmail');
if (lower.includes('rate limit')) return t('errorRateLimit');
if (lower.includes('invalid') || lower.includes('expired')) return t('errorInvalidOtp');
return error;
};

useEffect(() => {
if (isAuthenticated) {
router.replace('/dashboard');
}
}, [isAuthenticated, router]);

useEffect(() => {
return () => resetEmailAuth();
}, [resetEmailAuth]);

const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
signInWithEmail(email.trim());
};

const handleOtpChange = (index: number, value: string) => {
if (value.length > 1) {
const pasted = value.replace(/\D/g, '').slice(0, 6).split('');
const newDigits = [...otpDigits];
for (let i = 0; i < pasted.length; i++) {
if (index + i < 6) newDigits[index + i] = pasted[i];
}
setOtpDigits(newDigits);
const nextIndex = Math.min(index + pasted.length, 5);
otpInputRefs.current[nextIndex]?.focus();

if (newDigits.every((d) => d !== '')) {
verifyEmailOtp(pendingEmail!, newDigits.join('')).then((ok) => {
if (ok) router.replace('/dashboard');
});
}
return;
}

const digit = value.replace(/\D/g, '');
const newDigits = [...otpDigits];
newDigits[index] = digit;
setOtpDigits(newDigits);

if (digit && index < 5) {
otpInputRefs.current[index + 1]?.focus();
}

if (newDigits.every((d) => d !== '')) {
verifyEmailOtp(pendingEmail!, newDigits.join('')).then((ok) => {
if (ok) router.replace('/dashboard');
});
}
};

const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !otpDigits[index] && index > 0) {
otpInputRefs.current[index - 1]?.focus();
}
};

const handleBack = () => {
resetEmailAuth();
setOtpDigits(['', '', '', '', '', '']);
};

return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-200">
<Zap className="h-7 w-7 text-white" />
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
<div className="w-full max-w-[400px]">
<div className="text-center mb-8">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-200/50">
<span className="text-2xl font-bold text-white">E</span>
</div>
<CardTitle className="text-2xl font-bold text-slate-900">Sign in to EchoType</CardTitle>
<CardDescription className="text-slate-500">Sync your learning data across devices</CardDescription>
</CardHeader>

<CardContent className="space-y-4">
{authError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Sign in failed. Please try again.
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
{emailOtpSent ? t('checkEmail') : t('title')}
</h1>
<p className="mt-2 text-sm text-slate-500">
{emailOtpSent ? (
<>
{t('otpSentTo')} <span className="font-medium text-slate-700">{pendingEmail}</span>
</>
) : (
t('subtitle')
)}
</p>
{emailOtpSent && <p className="mt-1 text-xs text-slate-400">{t('otpHint')}</p>}
</div>

<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
{(authError || emailAuthError) && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{getLocalizedError(emailAuthError)}
</div>
)}

<Button
variant="outline"
className="w-full h-11 gap-3 text-sm font-medium"
onClick={() => signInWithGoogle()}
>
<GoogleIcon className="h-5 w-5" />
Continue with Google
</Button>

<Button
variant="outline"
className="w-full h-11 gap-3 text-sm font-medium"
onClick={() => signInWithGitHub()}
>
<GitHubIcon className="h-5 w-5" />
Continue with GitHub
</Button>

<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-400">or</span>
{emailOtpSent ? (
<div className="space-y-5">
<div className="flex justify-center gap-2">
{otpDigits.map((digit, i) => (
<input
key={`otp-${i}`}
ref={(el) => {
otpInputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
maxLength={6}
value={digit}
onChange={(e) => handleOtpChange(i, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(i, e)}
className="h-12 w-11 rounded-lg border border-slate-200 bg-slate-50 text-center text-lg font-semibold text-slate-900 outline-none transition-all focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20"
autoFocus={i === 0}
disabled={emailAuthLoading}
/>
))}
</div>

{emailAuthLoading ? (
<div className="flex items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t('verifying')}
</div>
) : (
<p className="text-center text-xs text-slate-400">{t('checkSpam')}</p>
)}

<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t('back')}
</button>
<button
type="button"
onClick={() => {
setOtpDigits(['', '', '', '', '', '']);
signInWithEmail(pendingEmail!);
otpInputRefs.current[0]?.focus();
}}
disabled={emailAuthLoading}
className="text-indigo-600 hover:text-indigo-700 font-medium transition-colors cursor-pointer disabled:opacity-50"
>
{t('resendCode')}
</button>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="h-11 gap-2 text-sm font-medium cursor-pointer"
onClick={() => signInWithGoogle()}
>
<GoogleIcon className="h-4 w-4" />
Google
</Button>
<Button
variant="outline"
className="h-11 gap-2 text-sm font-medium cursor-pointer"
onClick={() => signInWithGitHub()}
>
<GitHubIcon className="h-4 w-4" />
GitHub
</Button>
</div>

<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-3 text-slate-400">{t('orContinueWith')}</span>
</div>
</div>

<Link href="/dashboard" className="block">
<Button variant="ghost" className="w-full text-sm text-slate-500 hover:text-slate-700">
Continue without account
</Button>
</Link>
</CardContent>
</Card>
<form onSubmit={handleEmailSubmit} className="space-y-3">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1.5">
{t('emailLabel')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder={t('emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-9 h-11"
required
disabled={emailAuthLoading}
/>
</div>
</div>
<Button
type="submit"
className="w-full h-11 bg-indigo-600 hover:bg-indigo-700 text-white font-medium cursor-pointer"
disabled={emailAuthLoading || !email.trim()}
>
{emailAuthLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('sendingCode')}
</>
) : (
<>
{t('continueWithEmail')}
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</form>

<div className="mt-4 pt-4 border-t border-slate-100 text-center">
<Link href="/dashboard" className="text-sm text-slate-400 hover:text-slate-600 transition-colors">
{t('continueWithout')}
</Link>
</div>
</div>
)}
</div>
</div>
</div>
);
}
Loading
Loading