/* ============================================================================ * 서명전에 — Auth & API client (Toss anonymous hash + FastAPI) * * 책임: * 1) /client-config 1회 조회 → auth_mode / 토스 / 광고 설정 받아옴 * 2) auth_mode 분기: * - toss-anonymous : TossAdapter.auth.getAnonymousKey() 한 번 호출 → * synthetic session 생성 → 이후 모든 API 호출에 * Authorization: Bearer toss-hash: 로 인증. * - supabase-jwt (레거시): 기존 Supabase Auth 경로 유지. 마이그레이션 * 기간 동안만. G-9 검증 끝나면 .env 의 AUTH_MODE 가 toss-anonymous 로 * 전환되고, 이 경로는 죽은 코드가 됨 → G-10 정리 단계에서 제거. * 3) /account/profile 자동 조회 → 크레딧 동기화 * 4) /account/history 자동 조회 + 로컬 캐시 병합 * 5) useAuth() 훅으로 React 컴포넌트에 전역 상태 전파 * * 토스 익명 hash 인증의 핵심: * - hash 자체가 user_id 역할을 한다 (PII 없음, 미니앱별 고유). * - 별도의 JWT 교환이 없어서 session.access_token = "toss-hash:" 로 * 접두를 붙여 그대로 Authorization 헤더에 실어 보낸다. * - 재진입 시 토스 SDK 가 같은 hash 를 돌려주므로 자동 재로그인이 무료. * ========================================================================== */ (() => { const { useState: useStateAuth, useEffect: useEffectAuth } = React; // --- 로컬 캐시 (Supabase analysis_history 테이블이 아직 없을 때 대비) ------- // 서버가 히스토리 테이블을 갖고 있지 않아 저장이 실패해도, 최소한 같은 // 브라우저에서 분석한 기록은 최근 리스트에 남겨야 한다. 사용자가 "방금 // 분석한 게 기록에 왜 안 뜨지?" 하는 상황을 여기서 막는다. const LOCAL_HISTORY_KEY_PREFIX = "seomyungjeon:history:v1:"; const LOCAL_HISTORY_MAX = 20; /** 저장소 어댑터. 앱인토스에서는 localStorage 접근이 화이트아웃을 일으킬 수 있어 * TossAdapter.storage(→ Toss Storage SDK) 를 통해 접근한다. 웹에서는 동일 * 어댑터가 내부적으로 localStorage 로 폴백한다. 로드 순서 때문에 어댑터가 * 아직 안 떴을 때만 최후의 수단으로 localStorage 를 직접 쓴다. */ function tossStore() { const a = (typeof window !== "undefined" && window.TossAdapter) || null; return a?.storage || null; } function localHistoryKey(userId) { return `${LOCAL_HISTORY_KEY_PREFIX}${userId || "guest"}`; } async function readLocalHistory(userId) { try { let raw = null; const s = tossStore(); if (s?.getItem) { raw = await s.getItem(localHistoryKey(userId)); } else if (typeof window !== "undefined" && window.localStorage) { raw = window.localStorage.getItem(localHistoryKey(userId)); } if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } async function writeLocalHistory(userId, items) { const payload = JSON.stringify(items.slice(0, LOCAL_HISTORY_MAX)); try { const s = tossStore(); if (s?.setItem) { await s.setItem(localHistoryKey(userId), payload); } else if (typeof window !== "undefined" && window.localStorage) { window.localStorage.setItem(localHistoryKey(userId), payload); } } catch (err) { console.warn("[auth] localHistory write failed:", err); } } function mergeHistory(serverItems, localItems) { // 서버 우선 + 로컬 보조. 서버에 이미 있는 분석은 로컬 캐시에서 빼서 // "같은 파일이 두 번 뜨는" 문제를 방지한다. // 1) id(history_id) / local_id 로 직접 중복 제거 // 2) 로컬 항목이 서버 항목과 "같은 파일 + 5분 이내" 면 같은 분석으로 간주 const out = []; const seenIds = new Set(); const serverBuckets = new Map(); // filename -> [created_at_ms] const server = Array.isArray(serverItems) ? serverItems : []; const local = Array.isArray(localItems) ? localItems : []; for (const it of server) { if (!it) continue; const key = it.history_id || `srv:${it.filename}:${it.created_at}`; if (seenIds.has(key)) continue; seenIds.add(key); out.push(it); const bucket = serverBuckets.get(it.filename || "") || []; bucket.push(Date.parse(it.created_at || 0) || 0); serverBuckets.set(it.filename || "", bucket); } const WINDOW_MS = 5 * 60 * 1000; // 5분 for (const it of local) { if (!it) continue; const key = it.history_id || it.local_id || `loc:${it.filename}:${it.created_at}`; if (seenIds.has(key)) continue; // 서버에 같은 파일명이 최근에 들어왔으면 로컬은 버린다 (서버가 진실) const bucket = serverBuckets.get(it.filename || ""); if (bucket) { const localTs = Date.parse(it.created_at || 0) || 0; const near = bucket.some((ts) => Math.abs(ts - localTs) <= WINDOW_MS); if (near) continue; } seenIds.add(key); out.push(it); } out.sort((a, b) => { const ta = Date.parse(a.created_at || 0) || 0; const tb = Date.parse(b.created_at || 0) || 0; return tb - ta; }); return out; } // --- 전역 스토어 (간단한 pub/sub) ----------------------------------------- const store = { state: { ready: false, // /client-config 로딩 끝났나 configError: null, // 인증 모드. /client-config 응답에서 받아옴. UI 가 로그인 폼을 띄울지 // (supabase-jwt) vs. 무화면 hash 부트스트랩만 할지(toss-anonymous) 분기. authMode: "toss-anonymous", authEnabled: false, // (레거시) supabase-jwt 모드에서 supabase 클라가 떴는지 paymentsEnabled: false, tossClientKey: null, // Task #117 — 앱인토스 인앱광고 스캐폴드. /client-config 에서 내려오는 값. // ID 가 비어 있으면 UI 상의 배너·보상형 CTA 는 렌더 안 함(정책 위반 방지). adsBannerUnitId: null, adsRewardedUnitId: null, adsRewardedGrantUnits: 0, // Task #197 — 앱인토스 IAP 상품 ID 매핑 (콘솔 등록값). // 값이 null 이면 BillingSheet 가 결제 버튼을 비활성화하고 "결제 준비 중" 표시. iapProductIdSingleText: null, iapProductIdSingleOcr: null, iapProductIdPack: null, supabase: null, // Supabase 클라이언트 (toss-anonymous 모드에선 null) session: null, // { access_token, user, expires_at, ... } | null // toss-anonymous 모드에선 user = { id: hash, email: "" } 형태 — 다운스트림 // 코드(useAuth().user?.id)가 그대로 동작하도록 같은 인터페이스 유지. user: null, account: null, // { email, free_count, paid_credits, ... } | null accountLoading: false, authBusy: false, // 로그인/가입 진행 중 (supabase-jwt 한정) authError: null, // 마지막 에러 메시지 // toss-anonymous 모드에서 hash 발급 실패한 경우의 사용자 노출 메시지. // null 이면 정상. 값이 있으면 진입 차단 + 안내 화면. tossBootstrapError: null, history: [], // AnalysisHistoryItem[] — 최근 분석 목록 (서버+로컬 병합) historyLoading: false, historyError: null, historySource: "empty", // "server" | "local" | "merged" | "empty" }, listeners: new Set(), set(patch) { this.state = { ...this.state, ...patch }; this.listeners.forEach((fn) => fn()); }, subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }, }; // --- API 호출 헬퍼 ------------------------------------------------------- async function refreshAccount(accessToken) { if (!accessToken) return; store.set({ accountLoading: true }); try { const res = await fetch("/account/profile", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) { // 401이면 세션이 만료된 것. 일단 account만 비워두고 로그아웃은 onAuthStateChange 흐름에 맡김 if (res.status === 401) { store.set({ account: null, accountLoading: false }); return; } throw new Error(`계정 정보를 불러오지 못했어요 (HTTP ${res.status})`); } const data = await res.json(); store.set({ account: data, accountLoading: false }); } catch (err) { console.warn("[auth] refreshAccount failed:", err); store.set({ accountLoading: false }); } } async function refreshHistory(accessToken) { if (!accessToken) return; const userId = store.state.user?.id || store.state.account?.user_id || null; const local = await readLocalHistory(userId); store.set({ historyLoading: true, historyError: null }); try { const res = await fetch("/account/history", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) { if (res.status === 401) { store.set({ history: local, historyLoading: false, historySource: local.length ? "local" : "empty" }); return; } throw new Error(`기록을 불러오지 못했어요 (HTTP ${res.status})`); } const data = await res.json(); const serverItems = Array.isArray(data?.items) ? data.items : []; const merged = mergeHistory(serverItems, local); const source = serverItems.length && local.length ? "merged" : serverItems.length ? "server" : local.length ? "local" : "empty"; if (!serverItems.length && local.length) { // 서버가 비었다는 건 Supabase `analysis_history` 테이블이 없거나 // RLS로 조회가 안 되는 상태일 가능성이 높다. 콘솔에 힌트를 남겨 // 디버깅을 쉽게 한다. console.info( "[auth] 서버 히스토리가 비어서 로컬 캐시에서 불러왔어요. " + "Supabase에 analysis_history 테이블이 없거나 RLS 정책이 조회를 막고 있을 수 있어요." ); } store.set({ history: merged, historyLoading: false, historySource: source }); } catch (err) { console.warn("[auth] refreshHistory failed:", err); // 네트워크 실패 시에도 로컬 기록은 살려 둔다. store.set({ history: local, historyLoading: false, historyError: err?.message || String(err), historySource: local.length ? "local" : "empty", }); } } /* 분석이 막 끝났을 때 호출. /analyze 응답을 즉시 로컬 히스토리에 넣어 * 서버 저장 여부와 무관하게 기록 탭에 곧바로 떠 있게 한다. * (내부적으로 async — 호출부는 awaited 여부와 관계없이 동작) */ async function rememberAnalysis(result) { if (!result || typeof result !== "object") return; const userId = store.state.user?.id || store.state.account?.user_id || null; const existing = await readLocalHistory(userId); const now = new Date().toISOString(); const item = { // server history_id가 생기면 refreshHistory 쪽이 교체한다. 그 전까지는 // local_id로 유일하게 식별한다. history_id: "", local_id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, filename: result.filename || "분석한 계약서", contract_type_label: result.contract_type_label || "계약서", analysis_mode: result.analysis_mode || "llm", summary: (result.summary || "").trim(), issue_count: Array.isArray(result.issues) ? result.issues.length : 0, missing_count: Array.isArray(result.missing_items) ? result.missing_items.length : 0, page_count: result.page_count || 1, created_at: now, result, }; // 동일 파일명이면 최신 것으로 교체 const dedup = existing.filter((it) => (it.filename || "") !== item.filename); const next = [item, ...dedup].slice(0, LOCAL_HISTORY_MAX); await writeLocalHistory(userId, next); // 스토어에도 즉시 반영 (서버 refresh가 늦게 도착해도 UI에 바로 보이게) const serverOnly = (store.state.history || []).filter((it) => !it.local_id); store.set({ history: mergeHistory(serverOnly, next), historySource: "local", }); } /* 히스토리 한 건 삭제. * - 서버 저장분(history_id 있음) → DELETE /account/history/{id} 호출 * - 로컬 전용(local_id 만 있음) → 로컬 캐시에서만 제거 * 어느 쪽이든 스토어에서는 즉시 사라지게 하고, 로컬 캐시는 동기화한다. * 반환값: { ok: true } | { ok: false, error } */ async function deleteHistory(target) { if (!target) return { ok: false, error: new Error("삭제할 기록이 없어요") }; const accessToken = store.state.session?.access_token || null; const userId = store.state.user?.id || store.state.account?.user_id || null; const hid = typeof target === "string" ? target : (target.history_id || ""); const localId = typeof target === "string" ? "" : (target.local_id || ""); // 1) 서버에 저장된 건이면 DELETE 호출 if (hid && accessToken) { try { const res = await fetch(`/account/history/${encodeURIComponent(hid)}`, { method: "DELETE", headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok && res.status !== 404) { // 404는 이미 사라진 상태 — 성공과 동일하게 취급한다. const text = await res.text().catch(() => ""); throw new Error(text || `삭제하지 못했어요 (HTTP ${res.status})`); } } catch (err) { console.warn("[auth] deleteHistory server call failed:", err); return { ok: false, error: err }; } } // 2) 스토어에서 제거 const nextHistory = (store.state.history || []).filter((it) => { if (hid && it.history_id === hid) return false; if (localId && it.local_id === localId) return false; return true; }); store.set({ history: nextHistory }); // 3) 로컬 캐시 동기화 try { const local = await readLocalHistory(userId); const nextLocal = local.filter((it) => { if (hid && it.history_id === hid) return false; if (localId && it.local_id === localId) return false; return true; }); if (nextLocal.length !== local.length) { await writeLocalHistory(userId, nextLocal); } } catch (err) { console.warn("[auth] deleteHistory local cleanup failed:", err); // 로컬 정리는 실패해도 서버/스토어 상태는 이미 정리됨 — 그대로 성공 처리 } return { ok: true }; } // --- 초기화 -------------------------------------------------------------- let _initPromise = null; async function init() { if (_initPromise) return _initPromise; _initPromise = (async () => { try { const res = await fetch("/client-config"); if (!res.ok) throw new Error(`client-config HTTP ${res.status}`); const cfg = await res.json(); // 공통 환경값 — auth_mode 와 무관하게 먼저 스토어에 반영. const commonState = { authMode: cfg.auth_mode || "toss-anonymous", paymentsEnabled: !!cfg.payments_enabled, tossClientKey: cfg.toss_client_key || null, adsBannerUnitId: cfg.ads_banner_unit_id || null, adsRewardedUnitId: cfg.ads_rewarded_unit_id || null, adsRewardedGrantUnits: Number(cfg.ads_rewarded_grant_units) || 0, // IAP 상품 ID (Task #197) — 콘솔 등록 전엔 null. iapProductIdSingleText: cfg.iap_product_id_single_text || null, iapProductIdSingleOcr: cfg.iap_product_id_single_ocr || null, iapProductIdPack: cfg.iap_product_id_pack || null, }; if (cfg.auth_mode === "toss-login") { await _initTossLogin(commonState); } else if (cfg.auth_mode === "toss-anonymous") { // [DEPRECATED G-11] 서버가 실수로 이 모드로 설정된 경우의 fallback. // 사용자에게 재시작 안내. store.set({ ...commonState, ready: true, tossBootstrapError: "앱이 업데이트됐어요. 앱을 종료한 뒤 다시 열어 주세요.", }); } else { await _initSupabaseLegacy(cfg, commonState); } } catch (err) { console.error("[auth] init failed:", err); store.set({ ready: true, configError: err?.message || String(err), }); } })(); return _initPromise; } /* G-11: 토스 로그인(OAuth) 부트스트랩. * 1) TossAdapter.auth.appLogin() → { authorizationCode, referrer } * 2) POST /auth/toss-login/exchange → { accessToken, refreshToken, userKey, ... } * 3) synthetic session 합성 + localStorage 에 refreshToken 보관 (재진입 용도) * * 재진입 흐름: * - localStorage 에 refreshToken 이 있으면 먼저 POST /auth/toss-login/refresh 시도 * - 실패하면 appLogin() 다시 호출 (약관 이미 동의했으면 UI 없이 바로 code 반환) */ const TOSS_LOGIN_REFRESH_KEY = "toss_login_refresh_token_v1"; async function _readRefreshToken() { try { const s = tossStore(); if (s?.getItem) { return await s.getItem(TOSS_LOGIN_REFRESH_KEY); } if (typeof window !== "undefined" && window.localStorage) { return window.localStorage.getItem(TOSS_LOGIN_REFRESH_KEY); } } catch (_) { /* noop */ } return null; } async function _writeRefreshToken(token) { try { const s = tossStore(); if (s?.setItem) { await s.setItem(TOSS_LOGIN_REFRESH_KEY, token || ""); return; } if (typeof window !== "undefined" && window.localStorage) { if (token) window.localStorage.setItem(TOSS_LOGIN_REFRESH_KEY, token); else window.localStorage.removeItem(TOSS_LOGIN_REFRESH_KEY); } } catch (_) { /* noop */ } } async function _initTossLogin(commonState) { const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; // 1) refresh 시도 — 재진입 시 토스 로그인 UI 없이 바로 세션 복원 const savedRefresh = await _readRefreshToken(); if (savedRefresh) { const refreshOk = await _attemptTossLoginRefresh(savedRefresh, commonState); if (refreshOk) return; // 실패했으면 refresh token 무효 → 새 로그인 유도 await _writeRefreshToken(null); } // 2) 새 로그인 — appLogin() 호출 if (!adapter || !adapter.auth || typeof adapter.auth.appLogin !== "function") { store.set({ ...commonState, ready: true, tossBootstrapError: "앱이 아직 준비 중이에요. 잠시 뒤 다시 열어 주세요. (TossAdapter 미부착)", }); return; } let loginResult = null; try { loginResult = await adapter.auth.appLogin(); } catch (err) { console.error("[auth] appLogin threw:", err); loginResult = { authorizationCode: null, referrer: null, error: "ERROR" }; } if (!loginResult || !loginResult.authorizationCode) { const code = loginResult?.error || "ERROR"; const msg = code === "USER_CANCELLED" ? "로그인을 취소했어요. 다시 시도해 주세요." : "토스 로그인을 완료하지 못했어요. 잠시 뒤 다시 열어 주세요."; console.warn("[auth] appLogin rejected:", code); store.set({ ...commonState, ready: true, tossBootstrapError: msg }); return; } // 3) 백엔드에 인가 코드 교환 요청 try { const res = await fetch("/auth/toss-login/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: loginResult.authorizationCode, referrer: loginResult.referrer || "DEFAULT", }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); const msg = body?.detail || `로그인 교환 실패 (HTTP ${res.status})`; console.warn("[auth] exchange failed:", msg); store.set({ ...commonState, ready: true, tossBootstrapError: msg }); return; } const data = await res.json(); await _applyTossLoginSession(data, commonState); } catch (err) { console.error("[auth] exchange network error:", err); store.set({ ...commonState, ready: true, tossBootstrapError: "네트워크 오류로 로그인을 마치지 못했어요. 잠시 뒤 다시 시도해 주세요.", }); } } async function _attemptTossLoginRefresh(refreshToken, commonState) { try { const res = await fetch("/auth/toss-login/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if (!res.ok) return false; const data = await res.json(); await _applyTossLoginSession(data, commonState); return true; } catch (_) { return false; } } async function _applyTossLoginSession(data, commonState) { // 백엔드 응답 → frontend session 객체 const accessToken = data.access_token; const expiresAtSec = data.expires_in ? Math.floor(Date.now() / 1000) + Number(data.expires_in) : Math.floor(Date.now() / 1000) + 3600; const session = { access_token: accessToken, expires_at: expiresAtSec, user: { id: data.user_key, email: data.email || "", // 디버그: 서버에서 내려준 scope 확인용 toss_scope: data.scope || "", }, }; await _writeRefreshToken(data.refresh_token || ""); store.set({ ...commonState, ready: true, supabase: null, authEnabled: false, session, user: session.user, tossBootstrapError: null, account: { email: data.email || "", free_uses_remaining: Number(data.free_uses_remaining || 0), credits: Number(data.credits || 0), unlimited: !!data.unlimited, }, }); const cached = await readLocalHistory(session.user.id); if (cached.length) { store.set({ history: cached, historySource: "local" }); } refreshAccount(accessToken); refreshHistory(accessToken); } /* 레거시: Supabase 이메일/패스워드 경로. AUTH_MODE=supabase-jwt 일 때만 진입. * G-9 검증 끝나면 .env 에서 toss-anonymous 로 전환되어 더 이상 호출되지 않음. */ async function _initSupabaseLegacy(cfg, commonState) { if (!cfg.auth_enabled || !cfg.supabase_url || !cfg.supabase_anon_key) { store.set({ ...commonState, ready: true, configError: "로그인 설정이 빠져 있어요. 관리자에게 환경 변수(.env)를 확인해 달라고 해 주세요.", }); return; } if (!window.supabase || typeof window.supabase.createClient !== "function") { store.set({ ...commonState, ready: true, configError: "로그인에 필요한 라이브러리가 아직 안 떴어요. 잠시 뒤 새로고침해 주세요.", }); return; } // Supabase 세션 저장소를 TossAdapter.storage 로 라우팅한다. // 앱인토스에서는 Toss Storage SDK(install 후), 웹에서는 localStorage. // Supabase v2 는 async storage 를 공식 지원한다. const adapterStore = tossStore(); const supabaseStorage = adapterStore ? { getItem: (k) => adapterStore.getItem(k), setItem: (k, v) => adapterStore.setItem(k, v), removeItem: (k) => adapterStore.removeItem(k), } : undefined; // 어댑터가 없으면 Supabase 기본값(localStorage) 사용 const client = window.supabase.createClient(cfg.supabase_url, cfg.supabase_anon_key, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, ...(supabaseStorage ? { storage: supabaseStorage } : {}), }, }); // 세션 변경 구독 (로그인/로그아웃/토큰 갱신 등) client.auth.onAuthStateChange(async (_event, nextSession) => { store.set({ session: nextSession, user: nextSession?.user ?? null, }); if (nextSession?.access_token) { const cached = await readLocalHistory(nextSession.user?.id || null); if (cached.length) { store.set({ history: cached, historySource: "local" }); } refreshAccount(nextSession.access_token); refreshHistory(nextSession.access_token); } else { store.set({ account: null, history: [], historySource: "empty" }); } }); // 기존 세션 복원 (새로고침 시) const { data: { session } } = await client.auth.getSession(); store.set({ ...commonState, ready: true, supabase: client, authEnabled: true, session, user: session?.user ?? null, }); if (session?.access_token) { const cached = await readLocalHistory(session.user?.id || null); if (cached.length) { store.set({ history: cached, historySource: "local" }); } refreshAccount(session.access_token); refreshHistory(session.access_token); } } // --- 로그인/가입/로그아웃 ------------------------------------------------ // toss-anonymous 모드에서는 signIn / signUp 이 의미 없는 호출 — UI 가 // 이메일 폼을 띄우지 않으므로 호출 자체가 사라져야 정상. 안전망으로 명확한 // 에러를 돌려서 잘못된 호출을 디버깅 단계에서 잡는다. async function signIn(email, password) { if (store.state.authMode === "toss-anonymous") { const err = new Error("이 빌드는 토스 로그인만 지원해요. 이메일 로그인은 더 이상 사용하지 않아요."); console.warn("[auth] signIn called in toss-anonymous mode — ignoring"); return { ok: false, error: err }; } const client = store.state.supabase; if (!client) return { ok: false, error: new Error("로그인 준비가 아직 안 됐어요") }; store.set({ authBusy: true, authError: null }); try { const { data, error } = await client.auth.signInWithPassword({ email, password }); if (error) throw error; store.set({ authBusy: false }); return { ok: true, data }; } catch (err) { const msg = err?.message || "로그인하지 못했어요. 잠시 뒤 다시 시도해 주세요."; store.set({ authBusy: false, authError: msg }); return { ok: false, error: err }; } } async function signUp(email, password) { if (store.state.authMode === "toss-anonymous") { const err = new Error("이 빌드는 가입 절차가 없어요. 토스로 자동 로그인돼요."); console.warn("[auth] signUp called in toss-anonymous mode — ignoring"); return { ok: false, error: err }; } const client = store.state.supabase; if (!client) return { ok: false, error: new Error("가입 준비가 아직 안 됐어요") }; store.set({ authBusy: true, authError: null }); try { const { data, error } = await client.auth.signUp({ email, password }); if (error) throw error; store.set({ authBusy: false }); return { ok: true, data }; } catch (err) { const msg = err?.message || "가입하지 못했어요. 잠시 뒤 다시 시도해 주세요."; store.set({ authBusy: false, authError: msg }); return { ok: false, error: err }; } } /* 토스 익명 hash 모드의 "로그아웃" 정의: * - 백엔드 세션이 없어서 logout API 호출은 없음. * - 같은 hash 가 토스 SDK 캐시에 남아 있으므로 단순 setState 만으로는 의미가 * 없다 (다음 새로고침에 똑같은 hash 가 다시 발급됨). * - 그래서 우리는 (a) web-fallback hash 캐시를 비우고 (b) 로컬 스토어를 * 초기화한 뒤 (c) 새 hash 로 부트스트랩을 다시 돌린다. * - 결과적으로 "베타 데이터 리셋" 같은 사용자 경험을 만들 수 있다. * (실제 토스 환경에서는 SDK 가 같은 hash 를 줄 가능성이 높아 진짜 로그아웃은 * 아니지만, 사용자 시점에서 "내 흔적을 지웠다" 는 신호 역할을 한다.) */ async function signOut() { if (store.state.authMode === "toss-anonymous") { try { // (a) 웹 폴백 hash 캐시 제거 — 토스 SDK 캐시는 우리가 못 건드림. try { window.localStorage.removeItem("toss_anon_hash_web_fallback_v1"); } catch (_) { /* private mode */ } // (b) 스토어 초기화 — UI 가 즉시 "로그아웃 직후" 상태로 보이게. store.set({ session: null, user: null, account: null, history: [], historySource: "empty", }); // (c) 새 hash 로 다시 부트스트랩. _initPromise 를 비워서 init() 을 처음부터 돌림. _initPromise = null; await init(); } catch (err) { console.warn("[auth] toss signOut/rebootstrap failed:", err); } return; } const client = store.state.supabase; if (!client) return; try { await client.auth.signOut(); // onAuthStateChange가 session=null로 발생시키면 자동 정리됨 } catch (err) { console.warn("[auth] signOut failed:", err); } } function clearAuthError() { if (store.state.authError) store.set({ authError: null }); } // --- 인증된 API fetch 래퍼 ----------------------------------------------- // 이후 단계(업로드/결제/히스토리)에서 공용으로 쓸 helper. async function apiFetch(path, options = {}) { const s = store.state.session; const headers = new Headers(options.headers || {}); if (s?.access_token) headers.set("Authorization", `Bearer ${s.access_token}`); if (!headers.has("Accept")) headers.set("Accept", "application/json"); const res = await fetch(path, { ...options, headers }); return res; } // --- React 훅 ------------------------------------------------------------ function useAuth() { const [, force] = useStateAuth({}); useEffectAuth(() => store.subscribe(() => force({})), []); return { ...store.state, signIn, signUp, signOut, clearAuthError, refreshAccount: () => { const t = store.state.session?.access_token; if (t) return refreshAccount(t); }, refreshHistory: () => { const t = store.state.session?.access_token; if (t) return refreshHistory(t); }, rememberAnalysis, deleteHistory, }; } // 전역 노출 window.__authStore = store; window.useAuth = useAuth; window.initAuth = init; window.apiFetch = apiFetch; window.rememberAnalysis = rememberAnalysis; // 자동 초기화 init(); })();