/** * 구글 로그인(Google Identity Services) 어댑터. * * ★ 스크립트를 index.html 이 아니라 여기서 붙인다. client_id 가 없는 앱(내부 운영 화면)까지 * 구글 스크립트를 받아 오면, 쓰지도 않는 서드파티 요청이 모든 화면에 붙는다. * ★ 여기서 받는 건 `credential`(구글 ID 토큰) 하나뿐이다. 그걸 백엔드에 넘기면 그다음부터는 * 우리 토큰이다 — 구글 토큰을 세션으로 들고 다니지 않는다. * ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로" * 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다. */ // ★ 언어는 **스크립트 URL 의 hl** 로 잡는다. renderButton 의 locale 옵션은 안 먹었다 // (locale:'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다 — 실측). const SCRIPT_URL = 'https://accounts.google.com/gsi/client?hl=ko'; const SCRIPT_ID = 'google-identity-services'; export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? ''; /** 빈 값이면 구글 로그인 자체를 화면에 올리지 않는다 — 누르면 실패하는 버튼을 두지 않는다. */ export function isGoogleLoginEnabled(): boolean { return Boolean(GOOGLE_CLIENT_ID); } 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'; locale?: string; 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 { if (!isGoogleLoginEnabled()) return Promise.reject(new Error('GOOGLE_CLIENT_ID 없음')); 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('GIS 로드는 됐는데 accounts.id 가 없다')); }; const fail = () => { // 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기·사내망에서 한 번 막히는 일이 흔하다. loading = null; reject(new Error('GIS 스크립트를 받지 못했다')); }; 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; }