refactor(auth): migrate social login to next-auth
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* NextAuth App Router 入口
|
||||
*
|
||||
* 路径:/api/auth/[...nextauth]
|
||||
* 自动处理 NextAuth 所有内置路由:
|
||||
* /api/auth/signin/[provider]
|
||||
* /api/auth/callback/[provider]
|
||||
* /api/auth/signout
|
||||
* /api/auth/session
|
||||
* /api/auth/csrf
|
||||
* /api/auth/providers
|
||||
*/
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
import { authOptions } from "@/lib/auth/config";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -6,54 +6,54 @@
|
||||
*
|
||||
* 行为:
|
||||
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
||||
* - 真实 Facebook 登录在「Continue with Facebook」按钮中触发
|
||||
* - 社交登录直接调 `nextauth.facebookLogin()` / `nextauth.googleLogin()`(NextAuth 流程)
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { facebookLogin, googleLogin } from "@/lib/auth/nextauth";
|
||||
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import { GoogleSignInButton } from "./google-sign-in-button";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
import styles from "./auth-facebook-panel.module.css";
|
||||
|
||||
export interface AuthFacebookPanelProps {
|
||||
onSwitchToEmail: () => void;
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthFacebookPanel({
|
||||
onSwitchToEmail,
|
||||
onGoogleSuccess,
|
||||
}: AuthFacebookPanelProps) {
|
||||
export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
|
||||
const state = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [busy, setBusy] = useState<null | "facebook" | "google">(null);
|
||||
|
||||
const handleFacebook = async () => {
|
||||
setBusy("facebook");
|
||||
const r = await facebookLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy("google");
|
||||
const r = await googleLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<AuthErrorMessage message={state.errorMessage} />
|
||||
<AuthSocialButtons
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
onFacebook={() => dispatch({ type: "AuthFacebookLoginSubmitted" })}
|
||||
onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
|
||||
googleSlot={
|
||||
<GoogleSignInButton
|
||||
onSuccess={(s) =>
|
||||
onGoogleSuccess(s.idToken, s.email)
|
||||
}
|
||||
/>
|
||||
}
|
||||
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||
onFacebook={handleFacebook}
|
||||
onGoogle={handleGoogle}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<AuthPrimaryButton
|
||||
onClick={onSwitchToEmail}
|
||||
variant="primary"
|
||||
>
|
||||
<AuthPrimaryButton onClick={onSwitchToEmail} variant="primary">
|
||||
Continue with Email
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
@@ -69,21 +69,21 @@ export function AuthFacebookPanel({
|
||||
onClose={() => setShowOptions(false)}
|
||||
onFacebook={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthFacebookLoginSubmitted" });
|
||||
void handleFacebook();
|
||||
}}
|
||||
onGoogle={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthGoogleLoginSubmitted" });
|
||||
void handleGoogle();
|
||||
}}
|
||||
onApple={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthAppleLoginSubmitted" });
|
||||
console.warn("Apple login not implemented");
|
||||
}}
|
||||
onEmail={() => {
|
||||
setShowOptions(false);
|
||||
onSwitchToEmail();
|
||||
}}
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||
showApple={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,7 @@ import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import { AuthEmailPanel } from "./auth-email-panel";
|
||||
import styles from "./auth-panel.module.css";
|
||||
|
||||
export interface AuthPanelProps {
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
||||
export function AuthPanel() {
|
||||
const state = useAuthState();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
@@ -26,7 +22,6 @@ export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
||||
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
||||
// 实际上本组件直接渲染对应 panel
|
||||
}}
|
||||
onGoogleSuccess={onGoogleSuccess}
|
||||
/>
|
||||
) : (
|
||||
<AuthEmailPanel onBack={() => undefined} />
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||
import { useAuthGate } from "@/lib/auth/nextauth";
|
||||
import { ROUTES } from "@/lib/routes";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
||||
@@ -42,7 +42,7 @@ export function AuthScreen({
|
||||
if (isAuthed) router.replace(ROUTES.chat);
|
||||
}, [isAuthed, router]);
|
||||
|
||||
// 登录成功 → 通知 Chat + User + 跳转
|
||||
// 邮箱登录成功 → 通知 Chat + User + 跳转
|
||||
useEffect(() => {
|
||||
if (state.isSuccess && !wasSuccess.current) {
|
||||
wasSuccess.current = true;
|
||||
@@ -63,11 +63,7 @@ export function AuthScreen({
|
||||
padding: "var(--spacing-lg)",
|
||||
}}
|
||||
>
|
||||
<AuthPanel
|
||||
onGoogleSuccess={(idToken, email) => {
|
||||
authDispatch({ type: "AuthWebGoogleLoginSuccess", idToken, email });
|
||||
}}
|
||||
/>
|
||||
<AuthPanel />
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* Google 登录按钮(GIS 渲染)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/google_sign_in_web.dart
|
||||
*
|
||||
* 通过 `google.accounts.id.renderButton` 把 GIS 官方按钮渲染到指定 div。
|
||||
* 登录成功由 `setOnGoogleSignInSuccess` 设置的回调处理。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
renderGoogleButton,
|
||||
setOnGoogleSignInSuccess,
|
||||
type GoogleSignInSuccess,
|
||||
} from "@/integrations/google-identity";
|
||||
import styles from "./google-sign-in-button.module.css";
|
||||
|
||||
export interface GoogleSignInButtonProps {
|
||||
onSuccess: (success: GoogleSignInSuccess) => void;
|
||||
}
|
||||
|
||||
export function GoogleSignInButton({ onSuccess }: GoogleSignInButtonProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOnGoogleSignInSuccess(onSuccess);
|
||||
return () => setOnGoogleSignInSuccess(null);
|
||||
}, [onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
let cancelled = false;
|
||||
// GIS 异步加载,轮询直到可用
|
||||
const tryRender = () => {
|
||||
if (cancelled) return;
|
||||
if (window.google?.accounts?.id) {
|
||||
renderGoogleButton(ref.current!, { theme: "outline", size: "large" });
|
||||
} else {
|
||||
setTimeout(tryRender, 100);
|
||||
}
|
||||
};
|
||||
tryRender();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className={styles.slot} />;
|
||||
}
|
||||
@@ -17,5 +17,4 @@ export * from "./auth-text-field";
|
||||
export * from "./auth-validators";
|
||||
export * from "./email-login-form";
|
||||
export * from "./email-register-form";
|
||||
export * from "./google-sign-in-button";
|
||||
export * from "./__tests__/auth-validators.test";
|
||||
|
||||
+6
-13
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Script from "next/script";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import "./globals.css";
|
||||
|
||||
import { RootProviders } from "@/providers/root-providers";
|
||||
@@ -61,24 +61,17 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
// suppressHydrationWarning:next/font + Tailwind v4 偶发类名差异;详见
|
||||
// `src/lib/auth/use-auth-gate.tsx` 的"水合闪屏"说明。
|
||||
// `src/lib/auth/nextauth.ts` 的"水合闪屏"说明。
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{/* Facebook SDK JS(auth + splash 入口会用到 FB.login) */}
|
||||
<Script
|
||||
src="https://connect.facebook.net/en_US/sdk.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
{/* Google Identity Services(auth 入口会用到 google.accounts.id) */}
|
||||
<Script
|
||||
src="https://accounts.google.com/gsi/client"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<RootProviders>{children}</RootProviders>
|
||||
{/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
|
||||
<SessionProvider>
|
||||
<RootProviders>{children}</RootProviders>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,12 @@
|
||||
* `Row(children: [Skip 文本, SizedBox 26, Facebook 登录按钮])`
|
||||
*
|
||||
* 本组件是 splash 鉴权流程的**自治单元**:
|
||||
* - 直接消费 `useAuthGate` / `useAuthState` / `useAuthDispatch`
|
||||
* - 直接消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
||||
* - 消费 `useAuthGate`(内部用 useSession 监听 NextAuth session)
|
||||
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录流程)
|
||||
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
||||
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
||||
* - Skip 用 `<Link>` 保留 SPA 体验
|
||||
*
|
||||
* 设计要点:
|
||||
* - 仅两个元素:Skip 文本 + Facebook 登录按钮
|
||||
* - 居中布局 (MainAxisAlignment.center)
|
||||
* - Facebook 按钮:带渐变(primaryGradient)+ 圆角 24 + 高度 48
|
||||
* - 加载中:按钮内显示 spinner
|
||||
* - 社交登录按钮直接调 `nextauth.facebookLogin()`(NextAuth 流程)
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -24,7 +20,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||
import { facebookLogin, useAuthGate } from "@/lib/auth/nextauth";
|
||||
import { ROUTES } from "@/lib/routes";
|
||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||
import styles from "./splash-button.module.css";
|
||||
@@ -52,7 +48,7 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
||||
if (isAuthed) router.replace(ROUTES.chat);
|
||||
}, [isAuthed, router]);
|
||||
|
||||
// 登录成功跳 /chat + 通知 chat/user 初始化
|
||||
// 邮箱登录成功跳 /chat + 通知 chat/user 初始化
|
||||
useEffect(() => {
|
||||
if (state.isSuccess && !wasSuccess.current) {
|
||||
wasSuccess.current = true;
|
||||
@@ -62,6 +58,13 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
||||
}
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
const handleFacebookLogin = async () => {
|
||||
const r = await facebookLogin();
|
||||
if (!r.success) {
|
||||
console.error("[splash-button] Facebook login failed", r.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{/* Skip 文本链接(next/link 渲染为 <a>,保留 SPA 体验) */}
|
||||
@@ -77,12 +80,10 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
||||
|
||||
<div className={styles.separator} aria-hidden="true" />
|
||||
|
||||
{/* Facebook 登录按钮(带渐变) */}
|
||||
{/* 社交登录按钮(NextAuth Facebook OAuth) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authDispatch({ type: "AuthFacebookLoginSubmitted" });
|
||||
}}
|
||||
onClick={handleFacebookLogin}
|
||||
disabled={isLoading}
|
||||
className={styles.facebookButton}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user