更新时间:2026-10-22 GMT+08:00
链接复制成功!
RDS Supabase GitHub SSO演示源码配置指导
操作场景
本章节用于指导如何完整的创建演示Demo项目,并创建好所有的源码文件。请严格按照目录及文件结构,在本地创建项目工程及相关目录;并依次新建对应的文件,拷贝所有源码内容。
目录及文件结构
图1 Demo项目目录及文件结构概览
Demo项目目录及文件结构层次关系如下:
supabase_auth_github //项目根目录 ├── .env //Supabase环境变量配置 ├── index.html //首页界面 ├── package.json //打包依赖配置 ├── vite.config.js └── src //核心功能源码目录 ├── App.jsx ├── index.css ├── main.jsx ├── components │ ├── Dashboard.jsx │ └── Login.jsx ├── context │ ├── AuthContext.jsx │ └── LanguageContext.jsx ├── i18n │ └── index.js └── lib └── supabaseClient.js
文件内容
.env
# 替换为RDS Supabase的公网访问地址
VITE_SUPABASE_URL={supabase_url}
# 替换为RDS Supabase的Anon API Key
VITE_SUPABASE_ANON_KEY={supabase_api_key} 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 GitHub SSO 登录</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html> package.json
{
"name": "supabase-github-sso",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^6.3.0"
}
} vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173
}
}) App.jsx
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { LanguageProvider, useLang } from './context/LanguageContext'
import Login from './components/Login'
import Dashboard from './components/Dashboard'
function LangSwitch() {
const { lang, toggleLang } = useLang()
return (
<button className="lang-switch" onClick={toggleLang} aria-label="Switch language">
{lang === 'zh' ? 'EN' : '中'}
</button>
)
}
function ProtectedRoute({ children }) {
const { session, loading } = useAuth()
const { t } = useLang()
if (loading) {
return (
<div className="full-center">
<span className="spinner spinner-lg" />
<p>{t.loadingSession}</p>
</div>
)
}
if (!session) {
return <Navigate to="/" replace />
}
return children
}
function RootRoute() {
const { session, loading } = useAuth()
const { t } = useLang()
if (loading) {
return (
<div className="full-center">
<span className="spinner spinner-lg" />
<p>{t.loadingSession}</p>
</div>
)
}
if (session) {
return <Navigate to="/demo" replace />
}
return <Login />
}
export default function App() {
return (
<LanguageProvider>
<AuthProvider>
<LangSwitch />
<Routes>
<Route path="/" element={<RootRoute />} />
<Route
path="/demo"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</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;
--color-elevated: #f0fdf4;
--radius: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.04);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
background: linear-gradient(135deg, var(--color-bg-gradient-start) 0%, var(--color-bg-gradient-end) 100%);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
/* ===== 语言切换按钮 ===== */
.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: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow);
z-index: 100;
transition: all 0.2s ease;
}
.lang-switch:hover {
border-color: var(--color-primary);
color: var(--color-primary);
transform: scale(1.05);
}
/* ===== 装饰性背景光斑 ===== */
.auth-page,
.dashboard-page {
position: relative;
}
.auth-page::before,
.auth-page::after,
.dashboard-page::before,
.dashboard-page::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
pointer-events: none;
z-index: 0;
}
.auth-page::before,
.dashboard-page::before {
width: 400px;
height: 400px;
background: var(--color-primary);
top: -100px;
right: -100px;
}
.auth-page::after,
.dashboard-page::after {
width: 300px;
height: 300px;
background: var(--color-primary-dark);
bottom: -50px;
left: -50px;
opacity: 0.2;
}
.auth-page > *,
.dashboard-page > * {
position: relative;
z-index: 1;
}
/* ===== 全局居中加载 ===== */
.full-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
height: 100vh;
color: var(--color-text-secondary);
}
/* ===== 登录页 ===== */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.auth-card {
width: 100%;
max-width: 520px;
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 40px 32px;
text-align: center;
box-shadow: var(--shadow);
}
.auth-logo {
width: 84px;
height: 84px;
margin: 0 auto 20px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.auth-title {
margin-bottom: 24px;
white-space: nowrap;
}
.auth-title-highlight {
display: block;
font-size: 32px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
position: relative;
padding-bottom: 16px;
}
.auth-title-highlight::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-dark));
}
.auth-subtitle {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 28px;
letter-spacing: 0.02em;
}
.btn-github {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 20px;
font-size: 15px;
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
}
.btn-github:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.btn-github:active:not(:disabled) {
transform: scale(0.98);
}
.btn-github:disabled {
opacity: 0.6;
cursor: default;
}
.auth-error {
margin-top: 16px;
padding: 10px 12px;
font-size: 13px;
color: #991b1b;
background: var(--color-error-bg);
border: 1px solid #fecaca;
border-radius: 8px;
}
.auth-flow {
margin-top: 24px;
padding: 14px 16px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 10px;
text-align: left;
}
.auth-flow-title {
font-size: 12px;
font-weight: 600;
color: var(--color-primary-dark);
letter-spacing: 0.05em;
margin-bottom: 12px;
text-transform: uppercase;
}
.auth-flow-steps {
list-style: none;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.auth-flow-steps li {
display: flex;
gap: 8px;
align-items: flex-start;
}
.auth-flow-step-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.auth-flow-step-content {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.auth-flow-step-content strong {
font-size: 12px;
font-weight: 600;
color: var(--color-text);
}
.auth-flow-step-content span {
font-size: 11px;
color: var(--color-text-secondary);
line-height: 1.4;
}
/* ===== 仪表盘页 ===== */
.dashboard-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.dashboard-card {
width: 100%;
max-width: 520px;
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.dashboard-header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 24px 20px;
border-bottom: 1px solid var(--color-border);
background: linear-gradient(135deg, var(--color-bg-gradient-start), var(--color-bg-gradient-end));
}
.dashboard-header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.github-corner {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-card);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow);
transition: all 0.2s ease;
}
.github-corner:hover {
border-color: var(--color-primary);
color: var(--color-primary);
transform: scale(1.05);
}
.dashboard-theme {
white-space: nowrap;
}
.dashboard-theme-highlight {
display: inline-block;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
position: relative;
padding-bottom: 14px;
}
.dashboard-theme-highlight::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 40px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-dark));
}
.dashboard-welcome {
font-size: 15px;
font-weight: 400;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
}
.dashboard-welcome-name {
color: var(--color-primary-dark);
font-weight: 600;
}
.btn-logout:disabled {
opacity: 0.6;
cursor: default;
}
.profile {
display: flex;
align-items: center;
gap: 18px;
padding: 28px 24px;
}
.avatar {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
flex-shrink: 0;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
color: #fff;
font-size: 28px;
font-weight: 700;
}
.profile-info {
min-width: 0;
}
.profile-name {
font-size: 20px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-username {
color: var(--color-primary-dark);
font-size: 14px;
margin-bottom: 2px;
}
.profile-email {
color: var(--color-text-secondary);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-list {
padding: 0 24px 24px;
display: flex;
flex-direction: column;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
cursor: pointer;
transition: background 0.2s ease;
border-bottom: 1px solid var(--color-border);
}
.detail-row:first-child {
padding-top: 0;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-row:hover {
background: var(--color-bg);
margin: 0 -12px;
padding-left: 12px;
padding-right: 12px;
border-radius: 6px;
}
.detail-label {
color: var(--color-text-secondary);
font-size: 13px;
flex-shrink: 0;
font-weight: 500;
}
.detail-value {
font-size: 13px;
color: var(--color-text);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.detail-token {
color: var(--color-primary-dark);
}
.detail-row-token {
cursor: default;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.detail-row-token:hover {
background: transparent;
margin: 0;
padding-left: 0;
padding-right: 0;
border-radius: 0;
}
.token-field {
display: flex;
gap: 8px;
}
.token-input {
flex: 1;
padding: 8px 12px;
font-size: 13px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.token-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(62, 207, 142, 0.15);
}
.token-copy-btn {
flex-shrink: 0;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
}
.token-copy-btn:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.token-copy-btn:active:not(:disabled) {
transform: scale(0.96);
}
.token-copy-btn:disabled {
opacity: 0.6;
cursor: default;
}
.detail-row-logout {
cursor: default;
padding-top: 20px;
}
.detail-row-logout:hover {
background: transparent;
margin: 0;
padding-left: 0;
padding-right: 0;
border-radius: 0;
}
.btn-logout {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 20px;
font-size: 15px;
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
}
.btn-logout:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.btn-logout:active:not(:disabled) {
transform: scale(0.98);
}
/* ===== 加载动画 ===== */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-lg {
width: 36px;
height: 36px;
border-width: 3px;
border-color: var(--color-border);
border-top-color: var(--color-primary);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
) Dashboard.jsx
import { useAuth } from '../context/AuthContext'
import { useLang } from '../context/LanguageContext'
import { useNavigate } from 'react-router-dom'
import { useState, useMemo } from 'react'
export default function Dashboard() {
const { user, session, signOut } = useAuth()
const { t, lang } = useLang()
const navigate = useNavigate()
const [signingOut, setSigningOut] = useState(false)
const [tokenCopied, setTokenCopied] = useState(false)
const meta = user?.user_metadata ?? {}
const name = meta.full_name || meta.name || meta.user_name || t.githubUser
const username = meta.user_name
const avatar = meta.avatar_url
const email = user?.email || meta.email
const loginTime = useMemo(() => {
const raw = session?.created_at || user?.created_at
if (!raw) return '—'
const d = new Date(raw)
if (isNaN(d.getTime())) return '—'
return d.toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}, [session?.created_at, user?.created_at, lang])
const handleSignOut = async () => {
setSigningOut(true)
const ok = await signOut()
setSigningOut(false)
if (ok) navigate('/', { replace: true })
}
const copyToClipboard = async (text) => {
if (!text) return false
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
}
const handleCopyToken = async () => {
const token = session?.access_token
if (!token) return
const ok = await copyToClipboard(token)
if (ok) {
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 2000)
}
}
const handleCopy = async (text) => {
await copyToClipboard(text)
}
return (
<div className="dashboard-page">
<div className="dashboard-card">
<header className="dashboard-header">
<div className="dashboard-header-top">
<h2 className="dashboard-theme">
<span className="dashboard-theme-highlight">Supabase GitHub SSO</span>
</h2>
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="github-corner"
aria-label="GitHub"
>
<svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
/>
</svg>
</a>
</div>
<p className="dashboard-welcome">
{t.dashboardWelcome}
</p>
</header>
<div className="profile">
{avatar ? (
<img className="avatar" src={avatar} alt={name} />
) : (
<div className="avatar avatar-fallback">{name.charAt(0).toUpperCase()}</div>
)}
<div className="profile-info">
<h3 className="profile-name">{name}</h3>
{username && <p className="profile-username">@{username}</p>}
{email && <p className="profile-email">{email}</p>}
</div>
</div>
<div className="detail-list">
<div className="detail-row" onClick={() => handleCopy(user?.id)}>
<span className="detail-label">{t.userId}</span>
<span className="detail-value" title={user?.id}>{user?.id ?? '—'}</span>
</div>
<div className="detail-row" onClick={() => handleCopy(email)}>
<span className="detail-label">{t.email}</span>
<span className="detail-value" title={email}>{email ?? '—'}</span>
</div>
<div className="detail-row detail-row-token">
<span className="detail-label">{t.accessToken}</span>
<div className="token-field">
<input
type="password"
className="token-input"
value={session?.access_token ?? ''}
readOnly
/>
<button
type="button"
className="token-copy-btn"
onClick={handleCopyToken}
disabled={!session?.access_token}
>
{tokenCopied ? t.copied : t.copyToken}
</button>
</div>
</div>
<div className="detail-row">
<span className="detail-label">{t.loginMethod}</span>
<span className="detail-value">
{user?.app_metadata?.provider ?? 'github'}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t.loginTime}</span>
<span className="detail-value">{loginTime}</span>
</div>
<div className="detail-row detail-row-logout">
<button
type="button"
className="btn-logout"
onClick={handleSignOut}
disabled={signingOut}
>
{signingOut ? t.loggingOut : t.logout}
</button>
</div>
</div>
</div>
</div>
)
} Login.jsx
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { useLang } from '../context/LanguageContext'
export default function Login() {
const { signInWithGitHub, error } = useAuth()
const { t } = useLang()
const [pending, setPending] = useState(false)
const handleGitHubLogin = async () => {
setPending(true)
await signInWithGitHub()
setPending(false)
}
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path
fill="#1f2937"
d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.46-1.18-1.11-1.5-1.11-1.5-.91-.64.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.56 2.34 1.11 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.55-1.14-4.55-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.36 9.36 0 0 1 12 6.84c.85 0 1.71.12 2.51.35 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.48-.01 2.82 0 .27.18.6.69.49A10.26 10.26 0 0 0 22 12.25C22 6.58 17.52 2 12 2z"
/>
</svg>
</div>
<h1 className="auth-title">
<span className="auth-title-highlight">Supabase GitHub SSO</span>
</h1>
<p className="auth-subtitle">{t.loginSubtitle}</p>
<button
type="button"
className="btn-github"
onClick={handleGitHubLogin}
disabled={pending}
>
{pending ? (
<>
<span className="spinner" aria-hidden="true" />
{t.loginPending}
</>
) : (
<>
<svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
/>
</svg>
{t.loginButton}
</>
)}
</button>
{error && <p className="auth-error" role="alert">{error}</p>}
<div className="auth-flow">
<p className="auth-flow-title">{t.flowTitle}</p>
<ol className="auth-flow-steps">
<li>
<span className="auth-flow-step-num">1</span>
<div className="auth-flow-step-content">
<strong>{t.flowStep1Title}</strong>
<span>{t.flowStep1Desc}</span>
</div>
</li>
<li>
<span className="auth-flow-step-num">2</span>
<div className="auth-flow-step-content">
<strong>{t.flowStep2Title}</strong>
<span>{t.flowStep2Desc}</span>
</div>
</li>
<li>
<span className="auth-flow-step-num">3</span>
<div className="auth-flow-step-content">
<strong>{t.flowStep3Title}</strong>
<span>{t.flowStep3Desc}</span>
</div>
</li>
<li>
<span className="auth-flow-step-num">4</span>
<div className="auth-flow-step-content">
<strong>{t.flowStep4Title}</strong>
<span>{t.flowStep4Desc}</span>
</div>
</li>
</ol>
</div>
</div>
</div>
)
} AuthContext.jsx
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { supabase } from '../lib/supabaseClient'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [session, setSession] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let mounted = true
// 初始化时获取当前会话
supabase.auth.getSession().then(({ data, error }) => {
if (!mounted) return
if (error) {
setError(error.message)
} else {
setSession(data.session)
}
setLoading(false)
})
// 监听认证状态变化
const { data: listener } = supabase.auth.onAuthStateChange((_event, newSession) => {
setSession(newSession)
setError(null)
})
return () => {
mounted = false
listener.subscription.unsubscribe()
}
}, [])
// 使用 GitHub OAuth 登录
const signInWithGitHub = useCallback(async () => {
setError(null)
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: window.location.origin
}
})
if (error) {
setError(error.message)
return false
}
return true
}, [])
// 登出
const signOut = useCallback(async () => {
setError(null)
const { error } = await supabase.auth.signOut()
if (error) {
setError(error.message)
return false
}
setSession(null)
return true
}, [])
const value = {
session,
user: session?.user ?? null,
loading,
error,
signInWithGitHub,
signOut
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (ctx === null) {
throw new Error('useAuth 必须在 AuthProvider 内部使用')
}
return ctx
} LanguageContext.jsx
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { translations } from '../i18n'
const LanguageContext = createContext(null)
export function LanguageProvider({ children }) {
const [lang, setLang] = useState(() => {
const saved = localStorage.getItem('app-lang')
return saved === 'en' ? 'en' : 'zh'
})
useEffect(() => {
localStorage.setItem('app-lang', lang)
}, [lang])
const toggleLang = useCallback(() => {
setLang((prev) => (prev === 'zh' ? 'en' : 'zh'))
}, [])
const t = translations[lang]
const value = { lang, toggleLang, t }
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
}
export function useLang() {
const ctx = useContext(LanguageContext)
if (ctx === null) {
throw new Error('useLang must be used within LanguageProvider')
}
return ctx
} index.js
export const translations = {
zh: {
// Login page
loginSubtitle: '使用 GitHub 账号登录以继续',
loginButton: '使用 GitHub 登录',
loginPending: '正在跳转 GitHub...',
flowTitle: 'SSO 认证流程',
flowStep1Title: '发起授权',
flowStep1Desc: '点击登录后,应用通过 Supabase 向 GitHub OAuth 发起授权请求',
flowStep2Title: 'GitHub 授权',
flowStep2Desc: '跳转至 GitHub 页面,由您确认授权应用访问您的账号信息',
flowStep3Title: '回调换令牌',
flowStep3Desc: 'GitHub 授权后携带 code 回调本应用,Supabase 用 code 换取 Access Token',
flowStep4Title: '建立会话',
flowStep4Desc: 'Supabase 创建用户会话,自动返回本应用并完成登录',
// Dashboard
dashboardWelcome: '登录成功,欢迎您体验RDS AI应用开发平台',
userId: '用户 ID',
email: '邮箱',
accessToken: 'Access Token',
copyToken: '点击复制',
copied: '已复制',
loginMethod: '登录方式',
loginTime: '登录时间',
logout: '退出登录',
loggingOut: '登出中...',
githubUser: 'GitHub 用户',
// App loading
loadingSession: '正在加载会话...',
},
en: {
// Login page
loginSubtitle: 'Sign in with your GitHub account to continue',
loginButton: 'Sign in with GitHub',
loginPending: 'Redirecting to GitHub...',
flowTitle: 'SSO Authentication Flow',
flowStep1Title: 'Initiate Auth',
flowStep1Desc: 'After clicking, the app sends an OAuth request to GitHub via Supabase',
flowStep2Title: 'GitHub Authorize',
flowStep2Desc: 'Redirect to GitHub where you authorize the app to access your account',
flowStep3Title: 'Callback & Token',
flowStep3Desc: 'GitHub redirects back with a code, Supabase exchanges it for an Access Token',
flowStep4Title: 'Establish Session',
flowStep4Desc: 'Supabase creates a user session and returns to the app, completing login',
// Dashboard
dashboardWelcome: 'Login successful, welcome to RDS AI Application Development Platform',
userId: 'User ID',
email: 'Email',
accessToken: 'Access Token',
copyToken: 'Copy',
copied: 'Copied',
loginMethod: 'Login Method',
loginTime: 'Login Time',
logout: 'Sign Out',
loggingOut: 'Signing out...',
githubUser: 'GitHub User',
// App loading
loadingSession: 'Loading session...',
},
} supabaseClient.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) {
throw new Error(
'缺少 Supabase 环境变量,请在 .env 中配置 VITE_SUPABASE_URL 和 VITE_SUPABASE_ANON_KEY'
)
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true
}
}) 父主题: AI应用开发平台