Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: A0b 真实登录(Supabase Auth + profile/init)
Closes #44. http 模式走 Auth;mock 假登录保留。提供 server/lib/supabase.ts 供 C0 改用。

Co-authored-by: Cursor <cursoragent@cursor.com>
  • Loading branch information
AketfONG and cursoragent committed Oct 30, 2026
commit 665f9fda64de2285738aa06cdf6cffa12a6469d8
8 changes: 8 additions & 0 deletions docs/DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@

## A:AI 朋友線(Vincent)

### 2026-10-30|A0b 真實登入與 Supabase|Vincent(AI 協助)|分支 a/A0b-real-auth|Closes #44
- **做了什麼**:前端 `src/lib/supabase.ts`+`src/lib/api/friend.ts`(`VITE_API_MODE=http|mock`);LoginPage/Settings 接 Supabase Auth(mock 假登录保留);后端 `server/lib/{supabase,auth,profile}.ts`、`POST /api/profile/init`(JWT+users 表幂等);`tests/friend/auth.test.ts`。
- **为什么**:S0 已合;Jerry C0 Realtime/seed 等 A 的 `server/lib/supabase.ts`(见 #20/#48)。本机 Supabase 开发,不碰云端共用库。
- **改了哪些文件**:`src/lib/supabase.ts`、`src/lib/api/friend.ts`、`LoginPage`、`SettingsPage`、`useDemoStore.login` 可选 id、`server/lib/*`、`server/routes/friend.ts`、`tests/friend/auth.test.ts`、`src/vite-env.d.ts`。
- **验证**:tsc 过;vitest 16/16;本机注册→`profile/init` created:true→再调 created:false→users 有行;Vite `/api` 代理通。
- **坑**:`supabase status -o env` 的值带引号,写进 `.env.local` 会导致 PostgREST「No suitable key」;service_role JWT 必须与 status 完全一致。`.env.local` 不 commit。
- **待办**:云端 Supabase 专案(三人共用)仍需手动建;`gh auth login` 后开 Draft PR/跑 `task.sh`。

### 2026-10-30|A0 專案骨架、登入、共用元件、設計 token|Vincent(AI 協助)|直接 commit main(骨架階段)
- **做了什麼**:Next.js 14(App Router、TS strict、Tailwind)專案骨架:登入/註冊(Supabase Auth+`/api/profile/init` 冪等初始化三張表)、`middleware.ts`(next-intl+Supabase session+未登入導向)、Hum 主題 token 寫入 `app/globals.css`(含深色模式)、`components/ui/`(Button、Card、Modal、Avatar、AiBadge、ChatBubble、RiveAvatar、LanguageSwitcher、NavBar、SubmitButton)、五個頁面骨架(friend/lobby/history/profile/settings)、`lib/env.ts`(zod 驗證,缺值即報明確錯誤)、`lib/types.ts`+`lib/config.ts`(07 §5/§6 逐字)、`supabase/migrations/0001_init.sql`(07 §4 逐字)、A 線 contracts stub+fixtures,另建 B/C 線最小 stub 讓三人可同時開工。
- **為什麼這樣做**:走 M1「stub 先行」策略(07 §6);登入用 server actions 回傳 `{ ok, error }`(錯誤即值);初始化三張表集中在 `lib/profile/init.ts` 單一寫入者、重複呼叫安全。三語與 Rive 的決策見「共用與決策」。
Expand Down
56 changes: 56 additions & 0 deletions server/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* 后端 JWT 验证(A0b)。从 Authorization: Bearer <jwt> 取出 auth.users。
*/
import type { Context } from 'hono'
import { getServiceClient } from './supabase'

export type AuthedUser = {
authId: string
email: string
displayName: string
}

/** 解析 Bearer token;格式不对回 null。 */
export function parseBearer(header: string | undefined): string | null {
if (!header) return null
const m = /^Bearer\s+(\S+)$/i.exec(header.trim())
return m?.[1] ?? null
}

/**
* 用 service role 验证 JWT,回 auth 用户。
* 不抛错:失败回 null(由路由决定 401 文案)。
*/
export async function resolveAuthUser(jwt: string): Promise<AuthedUser | null> {
const supabase = getServiceClient()
const { data, error } = await supabase.auth.getUser(jwt)
if (error || !data.user) return null
const email = data.user.email ?? ''
const metaName = data.user.user_metadata?.display_name
const displayName =
typeof metaName === 'string' && metaName.trim()
? metaName.trim()
: email.split('@')[0] || '朋友'
return { authId: data.user.id, email, displayName }
}

/**
* Hono 辅助:成功回 AuthedUser;失败回已写好的 401 Response。
*/
export async function requireAuth(
c: Context,
): Promise<AuthedUser | Response> {
const jwt = parseBearer(c.req.header('Authorization'))
if (!jwt) {
return c.json({ ok: false, error: '请先登录' }, 401)
}
const user = await resolveAuthUser(jwt)
if (!user) {
return c.json({ ok: false, error: '登录已过期,请重新登录' }, 401)
}
return user
}

export function isAuthFailure(v: AuthedUser | Response): v is Response {
return v instanceof Response
}
73 changes: 73 additions & 0 deletions server/lib/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 确保 public.users 有对应 auth 用户的行(幂等)。A0b。
* users.id 是 app 内用户 id;auth_id 链到 auth.users。
*/
import { z } from 'zod'
import type { AuthedUser } from './auth'
import { getServiceClient } from './supabase'

const InitResultSchema = z.object({
userId: z.string().uuid(),
displayName: z.string(),
created: z.boolean(),
})

export type InitProfileResult = z.infer<typeof InitResultSchema>

export async function initUserProfile(authed: AuthedUser): Promise<InitProfileResult> {
const supabase = getServiceClient()

// 已有行:直接回
const existing = await supabase
.from('users')
.select('id, display_name')
.eq('auth_id', authed.authId)
.maybeSingle()

if (existing.error) {
throw new Error(`查用户失败:${existing.error.message}`)
}

if (existing.data) {
return InitResultSchema.parse({
userId: existing.data.id,
displayName: existing.data.display_name,
created: false,
})
}

// 新建
const inserted = await supabase
.from('users')
.insert({
auth_id: authed.authId,
display_name: authed.displayName,
})
.select('id, display_name')
.single()

if (inserted.error) {
// 并发注册时可能撞 unique:再查一次
if (inserted.error.code === '23505') {
const again = await supabase
.from('users')
.select('id, display_name')
.eq('auth_id', authed.authId)
.single()
if (again.data) {
return InitResultSchema.parse({
userId: again.data.id,
displayName: again.data.display_name,
created: false,
})
}
}
throw new Error(`创建用户失败:${inserted.error.message}`)
}

return InitResultSchema.parse({
userId: inserted.data.id,
displayName: inserted.data.display_name,
created: true,
})
}
26 changes: 26 additions & 0 deletions server/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* 后端 Supabase client(service role,仅 server/scripts 用)。A0b。
* C0 seed 等也会改用这里(见 Jerry PR #48 备注)。
*/
import { createClient, type SupabaseClient } from '@supabase/supabase-js'

let cached: SupabaseClient | null = null

/** 单例 service-role client。缺环境变量时抛错(简体中文)。 */
export function getServiceClient(): SupabaseClient {
if (cached) return cached
const url = process.env.SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) {
throw new Error('缺少 SUPABASE_URL 或 SUPABASE_SERVICE_ROLE_KEY,请检查 .env.local')
}
cached = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
})
return cached
}

/** 测试用:清掉单例,方便换假 client。 */
export function resetServiceClientForTest(): void {
cached = null
}
19 changes: 19 additions & 0 deletions server/routes/friend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
// A 線的 API 路由,掛在 /api 底下(例如 friendRoutes.get('/xxx') → GET /api/xxx)。擁有者:A。
// 回應格式:成功 { ok: true, data },失敗 { ok: false, error: '给用户看的简体中文讯息' }。
import { Hono } from 'hono'
import { isAuthFailure, requireAuth } from '../lib/auth'
import { initUserProfile } from '../lib/profile'

export const friendRoutes = new Hono()

/**
* POST /api/profile/init
* 登录后调用一次:在 users 表建行(幂等)。需 Bearer JWT。
*/
friendRoutes.post('/profile/init', async (c) => {
const authed = await requireAuth(c)
if (isAuthFailure(authed)) return authed

try {
const data = await initUserProfile(authed)
return c.json({ ok: true, data })
} catch (err) {
console.error('[friend] profile/init', err)
return c.json({ ok: false, error: '初始化档案失败,请稍后再试' }, 500)
}
})
31 changes: 25 additions & 6 deletions src/features/friend/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Button } from '@components/ui/Button'
import { RiveAvatar } from '@components/ui/RiveAvatar'
import { useDemoStore } from '@store/useDemoStore'
import { t } from '@i18n/t'
import { isHttpMode, register, login as authLogin, initProfile } from '@lib/api/friend'

/** 画面 1:登录/注册(假登录,任填即过) */
/** 画面 1:登录/注册。http 模式走 Supabase Auth,mock 模式假登录。 */
export function LoginPage() {
const login = useDemoStore((s) => s.login)
const navigate = useNavigate()
Expand All @@ -16,7 +17,7 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)

const submit = () => {
const submit = async () => {
if (!email || !password || (tab === 'register' && !nickname)) {
setError(t('login.errorMissingFields'))
return
Expand All @@ -27,10 +28,27 @@ export function LoginPage() {
}
setBusy(true)
setError(null)
setTimeout(() => {
login(email, tab === 'register' ? nickname : '小望')

try {
if (isHttpMode) {
// 真实 Auth:注册含昵称;登录态由 Supabase session 持有
const user =
tab === 'register'
? await register(email, password, nickname)
: await authLogin(email, password)
await initProfile() // 确保 users 表有行(幂等)
login(user.email, user.nickname, user.id)
} else {
// mock:假登录(Demo 备案)
await new Promise((r) => setTimeout(r, 600))
login(email, tab === 'register' ? nickname : '小望')
}
navigate('/friend', { replace: true })
}, 600)
} catch (e) {
setError(e instanceof Error ? e.message : t('login.errorGeneric'))
} finally {
setBusy(false)
}
}

return (
Expand Down Expand Up @@ -88,6 +106,7 @@ export function LoginPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoComplete="current-password"
/>
</label>
Expand All @@ -98,7 +117,7 @@ export function LoginPage() {
{busy ? t('login.loading') : tab === 'login' ? t('login.submitLogin') : t('login.submitRegister')}
</Button>

<p className="login-hint">{t('login.demoHint')}</p>
<p className="login-hint">{isHttpMode ? '本机 Supabase 模式' : t('login.demoHint')}</p>
</div>

<p className="login-footer">{t('login.footer')}</p>
Expand Down
15 changes: 14 additions & 1 deletion src/features/friend/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ import { Card, CardHead } from '@components/ui/Card'
import { useDemoStore } from '@store/useDemoStore'
import { t } from '@i18n/t'
import { AgentLogPanel } from '@components/AgentLogPanel'
import { isHttpMode, logout as authLogout } from '@lib/api/friend'

/** 画面 12:设置(dev_mode、主动频率、社交 Agent 开关、档位) */
export function SettingsPage() {
const s = useDemoStore()
const navigate = useNavigate()

const handleLogout = async () => {
if (isHttpMode) {
try {
await authLogout()
} catch {
// session 已失效也清本地
}
}
s.logout()
navigate('/', { replace: true })
}

return (
<div className="page">
<div className="page-head">
Expand Down Expand Up @@ -124,7 +137,7 @@ export function SettingsPage() {
{/* 账号:登出 */}
<Card>
<CardHead title="账号" sub="登出后需重新登录" />
<Button small variant="danger" onClick={s.logout}>
<Button small variant="danger" onClick={handleLogout}>
{t('settings.logout')}
</Button>
</Card>
Expand Down
83 changes: 83 additions & 0 deletions src/lib/api/friend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* A 线 API 层(docs/10 §3):同一函数依 VITE_API_MODE 回传 mock 或打 /api。
* A0b 先接:登录/注册/登出 + profile/init。
*/
import { supabase, isHttpMode } from '../supabase'
import type { User } from './types'

/** 注册:Supabase Auth signUp(meta 带昵称)。 */
export async function register(email: string, password: string, nickname: string): Promise<User> {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: { data: { display_name: nickname } },
})
if (error) throw new AuthError(error.message)
const authUser = data.user
if (!authUser) throw new AuthError('注册未返回用户')
return toUser(authUser.id, authUser.email ?? email, nickname)
}

/** 登录:Supabase Auth signInWithPassword。 */
export async function login(email: string, password: string): Promise<User> {
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw new AuthError(error.message)
const authUser = data.user
if (!authUser) throw new AuthError('登录未返回用户')
const nickname = (authUser.user_metadata?.display_name as string | undefined) ?? email.split('@')[0] ?? '朋友'
return toUser(authUser.id, authUser.email ?? email, nickname)
}

/** 登出。 */
export async function logout(): Promise<void> {
const { error } = await supabase.auth.signOut()
if (error) throw new AuthError(error.message)
}

/** 目前登录者(重整后还原 session 用);无 session 回 null。 */
export async function currentUser(): Promise<User | null> {
const { data } = await supabase.auth.getUser()
const authUser = data.user
if (!authUser) return null
const email = authUser.email ?? ''
const nickname = (authUser.user_metadata?.display_name as string | undefined) ?? email.split('@')[0] ?? '朋友'
return toUser(authUser.id, email, nickname)
}

/** 登录后调一次:确保 users 表有行(幂等)。 */
export async function initProfile(): Promise<void> {
const { data } = await supabase.auth.getSession()
const token = data.session?.access_token
if (!token) return
const res = await fetch('/api/profile/init', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
})
if (!res.ok) {
// 不致命:mock/演示模式可继续
console.warn('[friend] profile/init 失败', res.status)
}
}

function toUser(id: string, email: string, nickname: string): User {
return { id, email, nickname }
}

/** Auth 相关错误(讯息已转成给用户看的简体中文)。 */
export class AuthError extends Error {
constructor(message: string) {
super(toZh(message))
this.name = 'AuthError'
}
}

/** Supabase 英文错误 → 简体中文给用户看。 */
function toZh(message: string): string {
if (/invalid login credentials/i.test(message)) return '邮箱或密码不正确'
if (/user already registered|already been registered/i.test(message)) return '这个邮箱已经注册过了'
if (/password should be at least/i.test(message)) return '密码至少要 6 个字符'
if (/email not confirmed/i.test(message)) return '邮箱尚未验证,请查收邮件'
return '出了点问题,再试一次?'
}

export { isHttpMode }
13 changes: 13 additions & 0 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** 前端 Supabase client(anon key,浏览器可见)。A0b。 */
import { createClient } from '@supabase/supabase-js'

const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(
url ?? 'http://127.0.0.1:54321',
anonKey ?? 'PUBLIC_ANON_KEY_MISSING',
)

/** 是否走 http 模式(打 /api +真实 Supabase Auth);预设 mock。 */
export const isHttpMode = import.meta.env.VITE_API_MODE === 'http'
Loading