"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 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 통과.
94 lines
3.6 KiB
TypeScript
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;
|
|
}
|