承接前一天的內容,今天要來講前端怎麼實作登入機制。
講到登入機制,當初首先想到的問題是:
不要,因為真的太麻煩了。完整的帳號/密碼登入機制至少需要:
延伸還會需要:
而且實際使用上能用 Google 登入就用 Google 登入就好。
決定用 Google 登入之後,第二個問題是:
最簡單的做法,是每次呼叫 API 都附上 Google 的 Token,讓後端去驗簽章。
但這樣後端每次都要跟 Google 驗證,效能與維護上都不划算。
而且實際經驗上我**更熟悉用 JWT:自己發 Token、自己驗,**清楚又好管。
所以最後決定採用 Google OAuth + JWT 的組合架構。
接下來就一步步實作這套登入系統,讓使用者能安全地存取 MyMomentum。
npm install @react-oauth/google jwt-decode
我們需要在index.tsx 包裝GoogleOAuthProvider,這樣所有子組件都可以使用Google登入功能。
// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { AuthProvider } from "./contexts/AuthContext";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<GoogleOAuthProvider clientId="861849830529-44mlejpcr9jjhh19qbvib8vjfbo42gea.apps.googleusercontent.com">
<AuthProvider>
<App />
</AuthProvider>
</GoogleOAuthProvider>
</BrowserRouter>
</React.StrictMode>
);
這裡做什麼:在應用最上層設定 Google OAuth Provider,並包裝我們的認證 Context。
這段程式碼的重點:Provider 的順序很重要,GoogleOAuthProvider 要在最外層,AuthProvider 在內層,這樣才能正確傳遞 Google 登入功能到我們的認證系統。
GoogleOAuthProvider
與 clientId
GoogleOAuthProvider
是 Google 官方套件提供的 Context,它會把 Google 的登入 SDK 注入到 React 應用中。
要使用它,必須傳入一個 clientId
,這是你在 Google Cloud Console 裡面建立 OAuth 憑證時拿到的。
這個 ID 綁定了應用程式的來源(例如本機 http://localhost:3000 或正式域名),只有合法的來源才能使用登入。
在登入前的Intro頁面,讓用戶點擊 Google 按鈕後取得憑證,然後發送到後端驗證。
導入方式:
// IntroPage.tsx
import { GoogleLogin } from "@react-oauth/google";
import { authenticateWithGoogle } from "../services/auth";
import { useAuth } from "../contexts/AuthContext";
const IntroPage = () => {
const { setAccessToken } = useAuth();
// Google 登入後的憑證處理
const handleGoogleCredential = async (credential?: string) => {
if (!credential) return;
const { accessToken } = await authenticateWithGoogle(credential);
setAccessToken(accessToken);
};
return (
<GoogleLogin
onSuccess={(res) => handleGoogleCredential(res.credential)}
onError={() => console.error("Google 登入失敗")}
/>
);
};
export default IntroPage;
這段程式碼的重點
credential
(Google ID Token)。handleGoogleCredential
:把 Google ID Token 傳給後端,後端驗證成功後會回傳我們自己的 JWT。setAccessToken
:把 JWT 存起來(例如放進 Context 和 localStorage),之後呼叫 API 時會用到。補充一下,登入按鈕拿到 Google ID Token 之後,需要呼叫後端 API 來完成驗證。
建立一個 auth.ts
:
// src/services/auth.ts
const API_BASE = "http://localhost:8080";
export type AuthResponse = {
accessToken: string; // 後端回傳的 JWT
};
export const authenticateWithGoogle = async (
idToken: string
): Promise<AuthResponse> => {
const response = await fetch(`${API_BASE}/auth/google`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
});
if (!response.ok) throw new Error("驗證失敗");
return response.json();
};
這段程式碼的重點:
/auth/google
API。然後我們需要一個地方統一管理 JWT,這邊要:
建立了一個 AuthContext:
// src/contexts/AuthContext.tsx
const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [accessToken, setAccessTokenState] = useState<string | null>(null);
// 啟動時檢查 localStorage 中的 token
useEffect(() => {
const saved = localStorage.getItem("mm_access_token");
if (!saved) return;
try {
const decoded = jwtDecode<{ exp?: number }>(saved);
if (decoded?.exp && decoded.exp * 1000 < Date.now()) {
localStorage.removeItem("mm_access_token"); // 過期清除
return;
}
setAccessTokenState(saved);
} catch {
localStorage.removeItem("mm_access_token"); // 格式錯誤清除
}
}, []);
// 更新 token 並同步 localStorage
const setAccessToken = useCallback((token: string | null) => {
setAccessTokenState(token);
if (token) localStorage.setItem("mm_access_token", token);
else localStorage.removeItem("mm_access_token");
}, []);
const logout = useCallback(() => setAccessToken(null), [setAccessToken]);
return (
<AuthContext.Provider value={{ accessToken, isAuthenticated: !!accessToken, setAccessToken, logout }}>
{children}
</AuthContext.Provider>
);
};
這段程式碼的重點:
jwtDecode
檢查 token 過期時間。setAccessToken
和 logout
提供給元件使用。useMemo
/ useCallback
避免不必要重渲染。後續要打API都要加上Token,這邊寫一個簡單的服務就不用每次都手動加
// src/services/api.ts
export const useApi = () => {
const { accessToken, logout } = useAuth();
const headers: HeadersInit = useMemo(() => ({
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}), [accessToken]);
const get = useCallback(async <T>(endpoint: string): Promise<T | null> => {
const response = await fetch(`${API_BASE}${endpoint}`, { headers });
if (response.status === 401) { logout(); return null; }
return response.ok ? response.json() : null;
}, [headers, logout]);
return { get };
};
這段程式碼的重點:
headers
會隨 token 自動更新。401 Unauthorized
會自動登出。使用範例:
import { useApi } from "../services/api";
const MyComponent = () => {
const { get } = useApi();
const fetchActivities = async () => {
// 請求會自動帶上 Authorization: Bearer <token>
const activities = await get<Activity[]>("/api/activities");
console.log(activities);
};
return <button onClick={fetchActivities}>載入活動</button>;
};
如果有登出或是判斷到沒登入之類的,重新導入到IntroPage,讓使用者重新登入
// src/App.tsx
import { useAuth } from "./contexts/AuthContext";
import IntroPage from "./components/IntroPage";
import MainApp from "./components/MainApp";
const App = () => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <MainApp /> : <IntroPage />;
};
這段程式碼的重點:用 isAuthenticated
來決定顯示登入頁或主應用。
登出流程
// src/components/Header.tsx
import { useAuth } from "../contexts/AuthContext";
const Header = () => {
const { logout } = useAuth();
return (
<header className="flex justify-between items-center p-4 shadow-sm border-b">
<h1 className="text-xl font-bold">MyMomentum</h1>
<button onClick={logout} className="px-3 py-1 border rounded">
登出
</button>
</header>
);
};
export default Header;
這段程式碼的重點:點擊「登出」會清除 token 和 localStorage,App
會自動回到登入頁。
今天我們完成了 前端登入系統的完整實作,主要完成了:
isAuthenticated
控制頁面顯示,並實作登出流程。這樣下來,整個前端登入流程就完成了,從登入到 API 認證,再到登出,全部串起來。
明天就要來看 後端怎麼驗證 Google ID Token、發 JWT,前後端搭配起來,才能完成真正可用的登入機制。
本篇文章的程式碼已整理在 GitHub Repository,歡迎參考:
👉 https://github.com/troublord/my-momentum/tree/dev/frontend