更新时间:2026-10-22 GMT+08:00
链接复制成功!
RDS Supabase邮箱认证演示源码配置指导
操作场景
本章节用于指导如何完整的创建演示Demo项目,并创建好所有的源码文件。请严格按照目录及文件结构,在本地创建项目工程及相关目录;并依次新建对应的文件,拷贝所有源码内容。
目录及文件结构
图1 Demo项目目录及文件结构概览
Demo项目目录及文件结构层次关系如下:
supabase_auth_email //项目根目录
├── .env //Supabase环境变量配置(URL/Anon Key)
├── .env.example //环境变量示例模板
├── index.html //首页入口界面
├── package.json //打包依赖配置(React18 + Vite5 + Supabase)
├── vite.config.js //Vite构建配置
└── src //核心功能源码目录
├── App.jsx //根组件:按登录态分发页面,含中英切换
├── main.jsx //React应用入口挂载
├── index.css //全局样式
├── components //UI组件目录
│ ├── AuthForm.jsx //登录/注册表单
│ ├── ChangeEmail.jsx //修改邮箱组件
│ ├── Dashboard.jsx //登录后用户面板
│ ├── Message.jsx //通用消息提示
│ ├── ResetPassword.jsx //重置密码(请求/恢复模式)
│ └── ResetPasswordInline.jsx //内联重置密码
├── context //React状态管理目录
│ ├── AuthContext.jsx //认证状态(Supabase会话)
│ └── LanguageContext.jsx //中英语言切换
├── i18n //国际化文案目录
│ ├── en.js //英文文案
│ └── zh.js //中文文案
└── lib //工具库目录
├── errors.js //错误码→用户提示映射
└── supabase.js //Supabase客户端初始化 文件内容
.env
# 替换为RDS Supabase的公网访问地址
VITE_SUPABASE_URL={supabase_url}
# 替换为RDS Supabase的Anon API Key
VITE_SUPABASE_ANON_KEY={supabase_api_key} .env.example
VITE_SUPABASE_URL=http://10.83.34.249:8443 VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg1NzQ1MDc3LCJleHAiOjE5NDM0MjUwNzd9.YHK5wU2jJEviNPSCAxp6P658ZLXcUy3JudZOu21Gk0I
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Supabase 邮箱认证系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html> package.json
{
"name": "supabase-email-auth-test",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.98.1-canary.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
} vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
},
}) App.jsx
import { useState } from 'react'
import { AuthProvider, useAuth } from './context/AuthContext'
import { LanguageProvider, useLanguage } from './context/LanguageContext'
import AuthForm from './components/AuthForm'
import Dashboard from './components/Dashboard'
import ResetPassword from './components/ResetPassword'
function LanguageSwitch() {
const { language, toggleLanguage } = useLanguage()
return (
<button className="lang-switch" onClick={toggleLanguage}>
{language === 'zh' ? 'EN' : '中'}
</button>
)
}
function AppContent() {
const { user, loading, isPasswordRecovery } = useAuth()
const { t } = useLanguage()
const [showReset, setShowReset] = useState(false)
if (loading) {
return (
<div className="app-wrapper">
<LanguageSwitch />
<div className="loading-screen">
<div className="loading-spinner" />
<p>{t.common.loading}</p>
</div>
</div>
)
}
if (isPasswordRecovery) {
return (
<div className="app-wrapper">
<LanguageSwitch />
<ResetPassword type="recovery" />
</div>
)
}
if (user) {
return (
<div className="app-wrapper">
<LanguageSwitch />
<Dashboard />
</div>
)
}
if (showReset) {
return (
<div className="app-wrapper">
<LanguageSwitch />
<ResetPassword type="request" />
<button className="back-link" onClick={() => setShowReset(false)}>
{t.common.backToSignIn}
</button>
</div>
)
}
return (
<div className="app-wrapper">
<LanguageSwitch />
<AuthForm />
<button className="forgot-link" onClick={() => setShowReset(true)}>
{t.auth.forgotPassword}
</button>
</div>
)
}
export default function App() {
return (
<LanguageProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</LanguageProvider>
)
} index.css
:root {
--color-primary: #3ecf8e;
--color-primary-dark: #1a7f5a;
--color-bg: #f0fdf4;
--color-bg-gradient-start: #f0fdf4;
--color-bg-gradient-end: #dcfce7;
--color-card: #ffffff;
--color-text: #1f2937;
--color-text-secondary: #6b7280;
--color-border: #bbf7d0;
--color-error: #ef4444;
--color-error-bg: #fef2f2;
--color-success: #10b981;
--color-success-bg: #ecfdf5;
--color-info: #3b82f6;
--color-info-bg: #eff6ff;
--radius: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.04);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif;
background: linear-gradient(135deg, var(--color-bg-gradient-start) 0%, var(--color-bg-gradient-end) 100%);
color: var(--color-text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.app-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
position: relative;
}
.app-wrapper::before,
.app-wrapper::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
pointer-events: none;
z-index: 0;
}
.app-wrapper::before {
width: 400px;
height: 400px;
background: var(--color-primary);
top: -100px;
right: -100px;
}
.app-wrapper::after {
width: 300px;
height: 300px;
background: var(--color-primary-dark);
bottom: -50px;
left: -50px;
opacity: 0.2;
}
.app-wrapper > * {
position: relative;
z-index: 1;
}
.loading-screen {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--color-text-secondary);
}
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-card,
.reset-card,
.dashboard {
background: var(--color-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem;
width: 100%;
max-width: 420px;
border: 1px solid var(--color-border);
}
.auth-header {
text-align: center;
margin-bottom: 1.5rem;
}
.auth-logo {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.auth-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: 0.25rem;
}
.auth-subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.auth-tabs {
display: flex;
gap: 0;
margin-bottom: 1.25rem;
background: var(--color-bg);
border-radius: 8px;
padding: 4px;
}
.auth-tab {
flex: 1;
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.auth-tab--active {
background: var(--color-card);
color: var(--color-text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.auth-form,
.reset-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text);
}
.form-input {
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
color: var(--color-text);
background: var(--color-card);
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(62, 207, 142, 0.15);
}
.form-input::placeholder {
color: #9ca3af;
}
.password-strength {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.strength-bar {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s, background 0.3s;
}
.strength-weak { background: var(--color-error); }
.strength-medium { background: #f59e0b; }
.strength-strong { background: var(--color-success); }
.strength-label {
font-size: 0.75rem;
font-weight: 500;
min-width: 48px;
}
.strength-label-weak { color: var(--color-error); }
.strength-label-medium { color: #f59e0b; }
.strength-label-strong { color: var(--color-success); }
.btn-submit {
padding: 0.75rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
margin-top: 0.5rem;
}
.btn-submit:hover {
background: var(--color-primary-dark);
}
.btn-submit:active {
transform: scale(0.98);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.8125rem;
margin-bottom: 1rem;
border: 1px solid transparent;
}
.alert-icon {
font-weight: 700;
flex-shrink: 0;
}
.alert-success {
background: var(--color-success-bg);
color: #065f46;
border-color: #a7f3d0;
}
.alert-error {
background: var(--color-error-bg);
color: #991b1b;
border-color: #fecaca;
}
.alert-info {
background: var(--color-info-bg);
color: #1e40af;
border-color: #bfdbfe;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
flex-shrink: 0;
}
.dashboard-greeting {
font-size: 1.25rem;
font-weight: 700;
}
.dashboard-email {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.dashboard-details {
background: var(--color-bg);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.detail-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
font-weight: 600;
}
.detail-value {
font-size: 0.8125rem;
color: var(--color-text);
word-break: break-all;
}
.detail-id {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
}
.btn-signout {
width: 100%;
padding: 0.75rem;
background: var(--color-card);
color: var(--color-error);
border: 1px solid var(--color-error);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-signout:hover {
background: var(--color-error-bg);
}
.btn-signout--confirm {
background: var(--color-error);
color: #fff;
}
.btn-change-email {
width: 100%;
padding: 0.75rem;
background: var(--color-card);
color: var(--color-primary-dark);
border: 1px solid var(--color-primary);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.75rem;
}
.btn-change-email:hover {
background: rgba(62, 207, 142, 0.08);
}
.btn-reset-password {
width: 100%;
padding: 0.75rem;
background: var(--color-card);
color: var(--color-info);
border: 1px solid var(--color-info);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.75rem;
}
.btn-reset-password:hover {
background: var(--color-info-bg);
}
.btn-reauthenticate {
width: 100%;
padding: 0.75rem;
background: var(--color-card);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.75rem;
}
.btn-reauthenticate:hover {
background: var(--color-bg);
}
.btn-reauthenticate:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.reset-password-section {
background: var(--color-bg);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
}
.reset-password-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.reset-password-subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.reset-password-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reset-password-actions {
display: flex;
gap: 0.75rem;
}
.reset-password-actions .btn-submit {
flex: 1;
margin-top: 0;
}
.change-email-section {
background: var(--color-bg);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
}
.change-email-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.change-email-subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.change-email-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.change-email-actions {
display: flex;
gap: 0.75rem;
}
.change-email-actions .btn-submit {
flex: 1;
margin-top: 0;
}
.btn-cancel {
padding: 0.75rem 1rem;
background: var(--color-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel:hover {
background: var(--color-bg);
color: var(--color-text);
}
.btn-cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dashboard-email--pending {
color: var(--color-info);
font-style: italic;
}
.reset-card {
text-align: center;
}
.reset-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.reset-subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-bottom: 1.25rem;
}
.reset-form {
text-align: left;
}
.reset-success {
padding: 1rem 0;
}
.reset-success-icon {
margin-bottom: 1rem;
}
.reset-success .reset-subtitle {
margin-bottom: 0;
}
.forgot-link,
.back-link {
margin-top: 1rem;
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 0.8125rem;
cursor: pointer;
padding: 0.5rem;
transition: color 0.2s;
}
.forgot-link:hover,
.back-link:hover {
color: var(--color-primary-dark);
}
.lang-switch {
position: fixed;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-card);
color: var(--color-text);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow);
}
.lang-switch:hover {
border-color: var(--color-primary);
color: var(--color-primary);
transform: scale(1.05);
}
.magic-link-success {
text-align: center;
padding: 1rem 0;
}
.magic-link-success-icon {
margin-bottom: 1rem;
}
.magic-link-success-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.magic-link-success-subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-bottom: 0;
} main.jsx
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(<App />) AuthForm.jsx
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLanguage } from '../context/LanguageContext'
import Message from './Message'
import { getErrorMessage } from '../lib/errors'
function getPasswordStrength(password) {
if (!password) return { level: 0, label: '' }
let score = 0
if (password.length >= 6) score++
if (password.length >= 10) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^A-Za-z0-9]/.test(password)) score++
if (score <= 1) return { level: 1, label: 'weak' }
if (score <= 3) return { level: 2, label: 'medium' }
return { level: 3, label: 'strong' }
}
export default function AuthForm() {
const { signUp, signIn, sendMagicLink } = useAuth()
const { t, language } = useLanguage()
const [mode, setMode] = useState('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const [magicLinkSent, setMagicLinkSent] = useState(false)
const [sentEmail, setSentEmail] = useState('')
const strength = getPasswordStrength(password)
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setMessage({ type: '', text: '' })
if (mode === 'magiclink') {
const { error } = await sendMagicLink(email)
setLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setSentEmail(email)
setMagicLinkSent(true)
setMessage({ type: 'success', text: t.message.magicLinkSent })
setEmail('')
}
return
}
const action = mode === 'signin' ? signIn : signUp
const { data, error } = await action(email, password)
setLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
return
}
if (mode === 'signup' && data.user && !data.session) {
setMessage({ type: 'success', text: t.message.signUpSuccess })
setEmail('')
setPassword('')
} else {
setMessage({ type: 'success', text: mode === 'signin' ? t.message.signInSuccess : t.message.signUpCreated })
}
}
const switchMode = (newMode) => {
setMode(newMode)
setMessage({ type: '', text: '' })
setMagicLinkSent(false)
setSentEmail('')
}
const showPasswordField = mode !== 'magiclink'
return (
<div className="auth-card">
<div className="auth-header">
<div className="auth-logo">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="url(#grad)" />
<path d="M12 20l6 6 10-12" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#3ecf8e" />
<stop offset="1" stopColor="#1a7f5a" />
</linearGradient>
</defs>
</svg>
</div>
<h1 className="auth-title">{t.auth.title}</h1>
<p className="auth-subtitle">{t.auth.subtitle}</p>
</div>
<div className="auth-tabs">
<button
className={`auth-tab ${mode === 'signin' ? 'auth-tab--active' : ''}`}
onClick={() => switchMode('signin')}
>
{t.auth.signIn}
</button>
<button
className={`auth-tab ${mode === 'signup' ? 'auth-tab--active' : ''}`}
onClick={() => switchMode('signup')}
>
{t.auth.signUp}
</button>
<button
className={`auth-tab ${mode === 'magiclink' ? 'auth-tab--active' : ''}`}
onClick={() => switchMode('magiclink')}
>
{t.auth.magicLink}
</button>
</div>
<Message type={message.type} text={message.text} />
{magicLinkSent ? (
<div className="magic-link-success">
<div className="magic-link-success-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="24" fill="#3ecf8e" fillOpacity="0.15" />
<path d="M16 24l6 6 10-12" stroke="#3ecf8e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2 className="magic-link-success-title">{t.reset.checkEmail}</h2>
<p className="magic-link-success-subtitle" dangerouslySetInnerHTML={{ __html: t.reset.magicLinkEmailSent.replace('{email}', sentEmail) }} />
</div>
) : (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="form-field">
<label className="form-label" htmlFor="email">{t.auth.email}</label>
<input
id="email"
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t.auth.emailPlaceholder}
required
/>
</div>
{showPasswordField && (
<div className="form-field">
<label className="form-label" htmlFor="password">{t.auth.password}</label>
<input
id="password"
type="password"
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t.auth.passwordPlaceholder}
minLength={6}
required
/>
{mode === 'signup' && password && (
<div className="password-strength">
<div className="strength-bar">
<div
className={`strength-fill strength-${strength.label}`}
style={{ width: `${strength.level * 33.33}%` }}
/>
</div>
<span className={`strength-label strength-label-${strength.label}`}>
{t.password[strength.label]}
</span>
</div>
)}
</div>
)}
<button className="btn-submit" type="submit" disabled={loading}>
{loading
? t.auth.processing
: mode === 'signin'
? t.auth.signIn
: mode === 'signup'
? t.auth.createAccount
: t.auth.magicLink + ' 登录'}
</button>
</form>
)}
</div>
)
} ChangeEmail.jsx
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLanguage } from '../context/LanguageContext'
import Message from './Message'
import { getErrorMessage } from '../lib/errors'
function withTimeout(promise, ms) {
let timer
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
export default function ChangeEmail({ onCancel }) {
const { user, updateEmail } = useAuth()
const { t, language } = useLanguage()
const [newEmail, setNewEmail] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const handleSubmit = async (e) => {
e.preventDefault()
setMessage({ type: '', text: '' })
if (newEmail.trim().toLowerCase() === user.email?.toLowerCase()) {
setMessage({ type: 'error', text: t.changeEmail.sameEmail })
return
}
setLoading(true)
let result
try {
result = await withTimeout(updateEmail(newEmail.trim()), 15000)
} catch {
result = { error: { message: 'timeout' } }
}
setLoading(false)
const { error } = result
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setMessage({ type: 'success', text: t.changeEmail.success })
setNewEmail('')
}
}
return (
<div className="change-email-section">
<h2 className="change-email-title">{t.changeEmail.title}</h2>
<p className="change-email-subtitle">{t.changeEmail.subtitle}</p>
<Message type={message.type} text={message.text} />
<form className="change-email-form" onSubmit={handleSubmit}>
<div className="form-field">
<label className="form-label" htmlFor="new-email">{t.changeEmail.newEmail}</label>
<input
id="new-email"
type="email"
className="form-input"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t.changeEmail.newEmailPlaceholder}
required
/>
</div>
<div className="change-email-actions">
<button className="btn-submit" type="submit" disabled={loading}>
{loading ? t.changeEmail.sending : t.changeEmail.submit}
</button>
<button className="btn-cancel" type="button" onClick={onCancel} disabled={loading}>
{t.changeEmail.cancel}
</button>
</div>
</form>
</div>
)
} Dashboard.jsx
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLanguage } from '../context/LanguageContext'
import Message from './Message'
import ChangeEmail from './ChangeEmail'
import ResetPasswordInline from './ResetPasswordInline'
import { getErrorMessage } from '../lib/errors'
export default function Dashboard() {
const { user, signOut, reauthenticate, reauthCompleted } = useAuth()
const { t, language } = useLanguage()
const [message, setMessage] = useState({ type: '', text: '' })
const [confirming, setConfirming] = useState(false)
const [reauthLoading, setReauthLoading] = useState(false)
const [showChangeEmail, setShowChangeEmail] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false)
// 通过重新认证邮件链接返回页面后,提示重新认证成功
useEffect(() => {
if (reauthCompleted) {
setMessage({ type: 'success', text: t.message.reauthSuccess })
}
}, [reauthCompleted, t])
const handleSignOut = async () => {
const { error } = await signOut()
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
}
}
const handleReauthenticate = async () => {
setMessage({ type: '', text: '' })
setReauthLoading(true)
const { error } = await reauthenticate()
setReauthLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setMessage({ type: 'success', text: t.message.reauthSent })
}
}
const handleConfirmSignOut = () => {
if (confirming) {
handleSignOut()
} else {
setConfirming(true)
setTimeout(() => setConfirming(false), 3000)
}
}
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const createdAt = new Date(user.created_at)
const formattedDate = createdAt.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const formattedTime = createdAt.toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
})
return (
<div className="dashboard">
<div className="dashboard-header">
<div className="avatar">
{user.email?.charAt(0).toUpperCase()}
</div>
<div>
<h1 className="dashboard-greeting">{t.dashboard.welcome}</h1>
<p className="dashboard-email">{user.email}</p>
{user.new_email && (
<p className="dashboard-email dashboard-email--pending">
{t.changeEmail.title}: {user.new_email}
</p>
)}
</div>
</div>
<Message type={message.type} text={message.text} />
<div className="dashboard-details">
<div className="detail-row">
<span className="detail-label">{t.dashboard.userId}</span>
<span className="detail-value detail-id">{user.id}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t.dashboard.registered}</span>
<span className="detail-value">{formattedDate} {formattedTime}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t.dashboard.lastSignIn}</span>
<span className="detail-value">{new Date(user.last_sign_in_at).toLocaleString(locale)}</span>
</div>
</div>
{showChangeEmail ? (
<ChangeEmail onCancel={() => setShowChangeEmail(false)} />
) : showResetPassword ? (
<ResetPasswordInline onCancel={() => setShowResetPassword(false)} />
) : (
<>
<button
className="btn-change-email"
onClick={() => setShowChangeEmail(true)}
>
{t.dashboard.changeEmail}
</button>
<button
className="btn-reset-password"
onClick={() => setShowResetPassword(true)}
>
{t.dashboard.resetPassword}
</button>
<button
className="btn-reauthenticate"
onClick={handleReauthenticate}
disabled={reauthLoading}
>
{reauthLoading ? t.auth.processing : t.dashboard.reauthenticate}
</button>
</>
)}
<button
className={`btn-signout ${confirming ? 'btn-signout--confirm' : ''}`}
onClick={handleConfirmSignOut}
>
{confirming ? t.dashboard.confirmSignOut : t.dashboard.signOut}
</button>
</div>
)
} Message.jsx
export default function Message({ type, text }) {
if (!text) return null
const icons = {
success: '\u2713',
error: '\u2717',
info: '\u2139',
}
return (
<div className={`alert alert-${type}`}>
<span className="alert-icon">{icons[type] || ''}</span>
<span>{text}</span>
</div>
)
} ResetPassword.jsx
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLanguage } from '../context/LanguageContext'
import Message from './Message'
import { getErrorMessage } from '../lib/errors'
export default function ResetPassword({ type }) {
const { resetPassword, updatePassword } = useAuth()
const { t, language } = useLanguage()
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const [emailSent, setEmailSent] = useState(false)
const handleSendReset = async (e) => {
e.preventDefault()
setLoading(true)
setMessage({ type: '', text: '' })
const { error } = await resetPassword(email)
setLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setEmailSent(true)
setMessage({ type: 'success', text: t.message.resetSent })
}
}
const handleUpdatePassword = async (e) => {
e.preventDefault()
setLoading(true)
setMessage({ type: '', text: '' })
const { error } = await updatePassword(newPassword)
setLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setMessage({ type: 'success', text: t.message.passwordUpdated })
setNewPassword('')
}
}
if (type === 'recovery') {
return (
<div className="reset-card">
<h2 className="reset-title">{t.reset.newTitle}</h2>
<p className="reset-subtitle">{t.reset.newSubtitle}</p>
<Message type={message.type} text={message.text} />
<form className="reset-form" onSubmit={handleUpdatePassword}>
<div className="form-field">
<label className="form-label" htmlFor="new-password">{t.reset.newPassword}</label>
<input
id="new-password"
type="password"
className="form-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.auth.passwordPlaceholder}
minLength={6}
required
/>
</div>
<button className="btn-submit" type="submit" disabled={loading}>
{loading ? t.reset.updating : t.reset.updatePassword}
</button>
</form>
</div>
)
}
if (emailSent) {
return (
<div className="reset-card">
<div className="reset-success">
<div className="reset-success-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="24" fill="#3ecf8e" fillOpacity="0.15" />
<path d="M16 24l6 6 10-12" stroke="#3ecf8e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2 className="reset-title">{t.reset.checkEmail}</h2>
<p className="reset-subtitle" dangerouslySetInnerHTML={{ __html: t.reset.emailSent.replace('{email}', email) }} />
</div>
</div>
)
}
return (
<div className="reset-card">
<h2 className="reset-title">{t.reset.title}</h2>
<p className="reset-subtitle">{t.reset.subtitle}</p>
<Message type={message.type} text={message.text} />
<form className="reset-form" onSubmit={handleSendReset}>
<div className="form-field">
<label className="form-label" htmlFor="reset-email">{t.auth.email}</label>
<input
id="reset-email"
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t.auth.emailPlaceholder}
required
/>
</div>
<button className="btn-submit" type="submit" disabled={loading}>
{loading ? t.reset.sending : t.reset.sendResetLink}
</button>
</form>
</div>
)
} ResetPasswordInline.jsx
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLanguage } from '../context/LanguageContext'
import Message from './Message'
import { getErrorMessage } from '../lib/errors'
export default function ResetPasswordInline({ onCancel }) {
const { updatePassword } = useAuth()
const { t, language } = useLanguage()
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const handleSubmit = async (e) => {
e.preventDefault()
setMessage({ type: '', text: '' })
setLoading(true)
const { error } = await updatePassword(newPassword)
setLoading(false)
if (error) {
setMessage({ type: 'error', text: getErrorMessage(error, language) })
} else {
setMessage({ type: 'success', text: t.message.passwordUpdated })
setNewPassword('')
}
}
return (
<div className="reset-password-section">
<h2 className="reset-password-title">{t.resetPassword.title}</h2>
<p className="reset-password-subtitle">{t.resetPassword.subtitle}</p>
<Message type={message.type} text={message.text} />
<form className="reset-password-form" onSubmit={handleSubmit}>
<div className="form-field">
<label className="form-label" htmlFor="new-password">{t.resetPassword.newPassword}</label>
<input
id="new-password"
type="password"
className="form-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.auth.passwordPlaceholder}
minLength={6}
required
/>
</div>
<div className="reset-password-actions">
<button className="btn-submit" type="submit" disabled={loading}>
{loading ? t.resetPassword.updating : t.resetPassword.updatePassword}
</button>
<button className="btn-cancel" type="button" onClick={onCancel} disabled={loading}>
{t.resetPassword.cancel}
</button>
</div>
</form>
</div>
)
} AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
const REAUTH_PENDING_KEY = 'supabase_reauth_pending'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [isPasswordRecovery, setIsPasswordRecovery] = useState(false)
const [reauthCompleted, setReauthCompleted] = useState(false)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'PASSWORD_RECOVERY') {
setIsPasswordRecovery(true)
}
// 通过重新认证邮件链接回到页面时,标记重新认证已完成
if (event === 'SIGNED_IN' && localStorage.getItem(REAUTH_PENDING_KEY)) {
localStorage.removeItem(REAUTH_PENDING_KEY)
setReauthCompleted(true)
}
setUser(session?.user ?? null)
})
return () => subscription.unsubscribe()
}, [])
const signUp = async (email, password) => {
const { data, error } = await supabase.auth.signUp({ email, password })
return { data, error }
}
const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
return { data, error }
}
const signOut = async () => {
const { error } = await supabase.auth.signOut()
return { error }
}
const resetPassword = async (email) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: window.location.origin,
})
return { error }
}
const updatePassword = async (newPassword) => {
const { error } = await supabase.auth.updateUser({ password: newPassword })
if (!error) {
setIsPasswordRecovery(false)
}
return { error }
}
const updateEmail = async (newEmail) => {
const { data, error } = await supabase.auth.updateUser({ email: newEmail })
return { data, error }
}
const sendMagicLink = async (email) => {
const { data, error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: window.location.origin,
},
})
return { data, error }
}
const reauthenticate = async () => {
const { data, error } = await supabase.auth.reauthenticate()
if (!error) {
localStorage.setItem(REAUTH_PENDING_KEY, '1')
}
return { data, error }
}
const value = {
user,
loading,
isPasswordRecovery,
reauthCompleted,
signUp,
signIn,
signOut,
resetPassword,
updatePassword,
updateEmail,
sendMagicLink,
reauthenticate,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
} LanguageContext.jsx
import { createContext, useContext, useState } from 'react'
import zh from '../i18n/zh'
import en from '../i18n/en'
const translations = { zh, en }
const LanguageContext = createContext(null)
export function LanguageProvider({ children }) {
const [language, setLanguage] = useState('zh')
const t = translations[language]
const toggleLanguage = () => setLanguage(prev => prev === 'zh' ? 'en' : 'zh')
return (
<LanguageContext.Provider value={{ language, t, toggleLanguage }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider')
}
return context
} en.js
export default {
auth: {
title: 'Supabase Auth',
subtitle: 'Email authentication demo',
signIn: 'Sign In',
signUp: 'Sign Up',
magicLink: 'Magic Link',
email: 'Email',
password: 'Password',
emailPlaceholder: 'you@example.com',
passwordPlaceholder: 'Minimum 6 characters',
processing: 'Processing...',
createAccount: 'Create Account',
forgotPassword: 'Forgot password?',
},
password: {
weak: 'Weak',
medium: 'Medium',
strong: 'Strong',
},
reset: {
title: 'Reset Password',
subtitle: 'Enter your email to receive a reset link',
sendResetLink: 'Send Reset Link',
sending: 'Sending...',
newTitle: 'Set New Password',
newSubtitle: 'Enter your new password below',
newPassword: 'New Password',
updatePassword: 'Update Password',
updating: 'Updating...',
checkEmail: 'Check Your Email',
emailSent: 'We sent a password reset link to <strong>{email}</strong>',
magicLinkEmailSent: 'We sent a magic link to <strong>{email}</strong>',
},
dashboard: {
welcome: 'Welcome!',
userId: 'User ID',
registered: 'Registered',
lastSignIn: 'Last Sign In',
signOut: 'Sign Out',
confirmSignOut: 'Click again to confirm',
changeEmail: 'Change Email',
resetPassword: 'Reset Password',
reauthenticate: 'Re-authenticate',
},
changeEmail: {
title: 'Change Email',
subtitle: 'Enter a new email address and we will send a confirmation link',
newEmail: 'New Email',
newEmailPlaceholder: 'new@example.com',
submit: 'Send Confirmation',
sending: 'Sending...',
cancel: 'Cancel',
success: 'A confirmation email has been sent to your new address. Please check and click the link to complete the change.',
emailChanged: 'Email changed successfully!',
sameEmail: 'The new email is the same as the current one',
networkError: 'Request timed out or service unavailable. Please check your network and retry.',
},
resetPassword: {
title: 'Reset Password',
subtitle: 'Enter your new password below',
newPassword: 'New Password',
updatePassword: 'Update Password',
updating: 'Updating...',
cancel: 'Cancel',
},
message: {
signUpSuccess: 'Registration successful! Please check your email to confirm your account.',
signInSuccess: 'Welcome back!',
signUpCreated: 'Account created successfully!',
resetSent: 'Password reset email sent. Please check your inbox.',
passwordUpdated: 'Password updated successfully!',
magicLinkSent: 'A Magic Link has been sent to your email. Please click the link in the email to complete sign in.',
reauthSent: 'A re-authentication email has been sent. Please click the link in the email to complete re-authentication.',
reauthSuccess: 'Re-authentication successful!',
},
common: {
loading: 'Loading...',
backToSignIn: '← Back to Sign In',
},
} zh.js
export default {
auth: {
title: 'Supabase 认证',
subtitle: '邮箱认证系统',
signIn: '登录',
signUp: '注册',
magicLink: 'Magic Link',
email: '邮箱',
password: '密码',
emailPlaceholder: 'you@example.com',
passwordPlaceholder: '至少6位字符',
processing: '处理中...',
createAccount: '创建账户',
forgotPassword: '忘记密码?',
},
password: {
weak: '弱',
medium: '中',
strong: '强',
},
reset: {
title: '重置密码',
subtitle: '输入您的邮箱以接收重置链接',
sendResetLink: '发送重置链接',
sending: '发送中...',
newTitle: '设置新密码',
newSubtitle: '在下方输入您的新密码',
newPassword: '新密码',
updatePassword: '更新密码',
updating: '更新中...',
checkEmail: '检查您的邮箱',
emailSent: '我们已向 <strong>{email}</strong> 发送了密码重置链接',
magicLinkEmailSent: '我们已向 <strong>{email}</strong> 发送了魔法链接',
},
dashboard: {
welcome: '欢迎回来!',
userId: '用户 ID',
registered: '注册时间',
lastSignIn: '最后登录',
signOut: '退出登录',
confirmSignOut: '再次点击确认',
changeEmail: '更换邮箱',
resetPassword: '重置密码',
reauthenticate: '重新认证',
},
changeEmail: {
title: '更换邮箱',
subtitle: '输入新的邮箱地址,我们将发送确认邮件',
newEmail: '新邮箱',
newEmailPlaceholder: 'new@example.com',
submit: '发送确认邮件',
sending: '发送中...',
cancel: '取消',
success: '确认邮件已发送至新邮箱,请查收并点击确认链接以完成更换。',
emailChanged: '邮箱更换成功!',
sameEmail: '新邮箱与当前邮箱相同',
networkError: '请求超时或服务不可用,请检查网络后重试。',
},
resetPassword: {
title: '重置密码',
subtitle: '在下方输入您的新密码',
newPassword: '新密码',
updatePassword: '更新密码',
updating: '更新中...',
cancel: '取消',
},
message: {
signUpSuccess: '注册成功!请检查您的邮箱以确认账户。',
signInSuccess: '欢迎回来!',
signUpCreated: '账户创建成功!',
resetSent: '密码重置邮件已发送,请检查您的邮箱。',
passwordUpdated: '密码更新成功!',
magicLinkSent: 'Magic Link 已发送至您的邮箱,请点击邮件中的链接完成登录。',
reauthSent: '重新认证邮件已发送,请点击邮件中的链接完成认证。',
reauthSuccess: '重新认证成功!',
},
common: {
loading: '加载中...',
backToSignIn: '← 返回登录',
},
} errors.js
const errorMessages = {
zh: {
'Invalid login credentials': '登录凭证无效,请检查邮箱和密码',
'Email not confirmed': '邮箱未确认,请先确认您的邮箱',
'User already registered': '该邮箱已注册',
'Password should be at least 6 characters': '密码至少6位字符',
'Unable to validate email address: invalid format': '邮箱格式无效',
'rate_limit_exceeded': '请求过于频繁,请稍后重试',
'timeout': '请求超时或服务不可用,请检查网络后重试',
'default': '操作失败,请重试',
},
en: {
'Invalid login credentials': 'Invalid login credentials',
'Email not confirmed': 'Email not confirmed. Please verify your email first',
'User already registered': 'This email is already registered',
'Password should be at least 6 characters': 'Password must be at least 6 characters',
'Unable to validate email address: invalid format': 'Invalid email format',
'rate_limit_exceeded': 'Too many requests. Please try again later',
'timeout': 'Request timed out or service unavailable. Please check your network and retry',
'default': 'Operation failed. Please try again',
},
}
export function getErrorMessage(error, language = 'zh') {
if (!error) return ''
const message = error.message || 'default'
const translations = errorMessages[language] || errorMessages.zh
return translations[message] || translations.default
} supabase.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY in .env')
}
export const supabase = createClient(supabaseUrl || '', supabaseAnonKey || '') 父主题: AI应用开发平台