playreel/frontend/lib/auth.ts
2026-09-15 16:00:54 +09:00

123 lines
3.7 KiB
TypeScript

"use client";
/**
* 구글 로그인.
* 브라우저가 구글에서 받은 credential 을 서버에 한 번 넘기면, 그 뒤로는 HttpOnly 쿠키가
* 붙는다 — 토큰을 프론트가 들고 있지 않는다.
*
* GIS 스크립트는 여기서 붙인다. 로그인 화면에 오기 전에는 서드파티 요청을 만들지 않는다.
*/
import { useEffect, useState } 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;
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<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
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("구글 스크립트에 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<AuthConfig>("/api/auth/config");
export const signIn = (credential: string) =>
apiFetch<Me>("/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" });
/** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */
export function useMe() {
const [me, setMe] = useState<Me | null | false>(null);
const refresh = () =>
apiFetch<Me>("/api/auth/me").then(setMe).catch(() => setMe(false));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 확인
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { me, refresh, setMe };
}