o2o-site-AEO/solution/frontend/src/lib/googleIdentity.ts
Mina Choi 128596fc74 [feat] solution/frontend: 구글 버튼을 로그인 관문에도 · 문구 한글화
"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 LoginPage·SignupPage 에만 붙여 뒀는데,
이 앱에서 사장님이 실제로 로그인 화면을 만나는 자리는 **에디터 진입 관문**(EditorSignInGate →
SignInForm)이다. 정작 거기엔 없었다.

- features/auth/SignInForm: 구글 버튼 추가. 관문·로그인 화면이 같은 폼을 쓰므로 한 곳만 고치면 된다
- lib/googleIdentity: 스크립트를 ?hl=ko 로 받는다. renderButton 의 locale 옵션은 안 먹었다 —
  'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다(실측)
- 버튼 문구는 signin_with('Google 계정으로 로그인'). 가입 화면만 signup_with 로 둔다.
  문구 자체는 고를 수 없다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다

브라우저 확인(localhost:80): 로그인 화면에 'Google 계정으로 로그인' 한글 노출.
tsc·eslint·vite build 통과.
2026-09-02 22:34:28 +09:00

94 lines
3.6 KiB
TypeScript

/**
* 구글 로그인(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<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
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<GoogleIdApi>((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;
}