playreel/frontend/lib/auth.ts
2026-09-16 10:46:33 +09:00

142 lines
4.4 KiB
TypeScript

"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;
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" });
// 로그인 정보는 화면 여러 곳이 같이 본다 — 사이드바 배지와 페이지가 따로 들고 있으면
// 한쪽에서 갱신해도 다른 쪽이 옛 값을 그린다. 한 군데 두고 나눠 쓴다
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<void> {
return apiFetch<Me>("/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 };
}