"use client"; /** * 구글 로그인. * 브라우저가 구글에서 받은 credential 을 서버에 한 번 넘기면, 그 뒤로는 HttpOnly 쿠키가 * 붙는다 — 토큰을 프론트가 들고 있지 않는다. * * GIS 스크립트는 여기서 붙인다. 로그인 화면에 오기 전에는 서드파티 요청을 만들지 않는다. */ import { useEffect, useSyncExternalStore } from "react"; import { apiFetch } from "@/lib/api"; const SCRIPT_URL = "https://accounts.google.com/gsi/client?hl=ko"; const SCRIPT_ID = "google-identity-services"; export interface Me { email: string; name: string; picture: string; jobs_created: number; job_limit: number; jobs_left: number; } export interface AuthConfig { enabled: boolean; client_id: string; } type CredentialResponse = { credential?: string }; export type GoogleButtonOptions = { type?: "standard" | "icon"; theme?: "outline" | "filled_blue" | "filled_black"; size?: "small" | "medium" | "large"; shape?: "rectangular" | "pill" | "circle" | "square"; text?: "signin_with" | "signup_with" | "continue_with" | "signin"; width?: number; logo_alignment?: "left" | "center"; }; type GoogleIdApi = { initialize(config: { client_id: string; callback: (response: CredentialResponse) => void; auto_select?: boolean; cancel_on_tap_outside?: boolean; }): void; renderButton(parent: HTMLElement, options: GoogleButtonOptions): void; disableAutoSelect(): void; }; declare global { interface Window { google?: { accounts?: { id?: GoogleIdApi } }; } } // 스크립트는 한 번만 받는다 let loading: Promise | null = null; export function loadGoogleIdentity(): Promise { const ready = window.google?.accounts?.id; if (ready) return Promise.resolve(ready); if (loading) return loading; loading = new Promise((resolve, reject) => { const done = () => { const api = window.google?.accounts?.id; if (api) resolve(api); else reject(new Error("구글 스크립트에 accounts.id 가 없습니다")); }; const fail = () => { // 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기나 사내망에서 한 번 막히는 일이 흔하다 loading = null; reject(new Error("구글 로그인 스크립트를 받지 못했습니다")); }; const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; if (existing) { existing.addEventListener("load", done, { once: true }); existing.addEventListener("error", fail, { once: true }); return; } const script = document.createElement("script"); script.id = SCRIPT_ID; script.src = SCRIPT_URL; script.async = true; script.defer = true; script.addEventListener("load", done, { once: true }); script.addEventListener("error", fail, { once: true }); document.head.appendChild(script); }); return loading; } export const fetchConfig = () => apiFetch("/api/auth/config"); export const signIn = (credential: string) => apiFetch("/api/auth/google", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credential }), }); export const signOut = () => apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); // 로그인 정보는 화면 여러 곳이 같이 본다 — 사이드바 배지와 페이지가 따로 들고 있으면 // 한쪽에서 갱신해도 다른 쪽이 옛 값을 그린다. 한 군데 두고 나눠 쓴다 let cached: Me | null | false = null; const listeners = new Set<() => void>(); function publish(next: Me | null | false) { cached = next; listeners.forEach((notify) => notify()); } function subscribe(notify: () => void) { listeners.add(notify); return () => { listeners.delete(notify); }; } /** 서버에 다시 물어본다. 잡을 만든 뒤처럼 남은 개수가 바뀌는 자리에서 부른다 */ export function refreshMe(): Promise { return apiFetch("/api/auth/me") .then((me) => publish(me)) .catch(() => publish(false)); } export const setMe = publish; /** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */ export function useMe() { const me = useSyncExternalStore(subscribe, () => cached, () => null); useEffect(() => { if (cached === null) refreshMe(); }, []); return { me, refresh: refreshMe, setMe: publish }; }