PageSourceSearch

https://seonamhae-grand.pages.dev/auth.js

js seonamhae-grand.pages.dev collected 2026-10-03 05:32:37 UTC 9,522 bytes, 189 lines download raw bytes

1/*! 서남해 그랜드 L.O.V.E — © 2026 taehyun (salutth · 미치광이 구름) · 너른빛스토리 · 사회혁신교육원. All Rights Reserved. PROPRIETARY. */
2/*
3 * ─── 로그인 스텁 (P5 MVP · Supabase) ──────────────────────────────────────
4 * 정직 라벨: 이 파일은 "동작하는 껍데기(stub)"다. auth-config.js 의 키가 비어 있으면
5 *   · 외부(supabase-js) 로드 안 함  · 로그인 버튼은 "설정 대기"로 비활성
6 *   · 어떤 에러도 던지지 않음
7 * 키가 채워지면 그 순간: supabase-js 지연 로드 → OAuth 로그인 → 세션·역할 반영.
8 *
9 * §10 확정 반영: 관광객=구글/애플, 담당자·관리자=카카오(혼합).  1P=1원·정산은 백엔드(08).
10 * ─────────────────────────────────────────────────────────────────────────
11 */
12(function () {
13  "use strict";
14
15  var SUPABASE_JS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2";
16  var PROVIDER_LABEL = { google: "Google", apple: "Apple", kakao: "Kakao" };
17
18  var cfg = window.SG_SUPABASE || { url: "", anonKey: "" };
19  var providers = window.SG_AUTH_PROVIDERS || { tourist: ["google", "apple"], gov: ["kakao"] };
20  var configured = !!(cfg.url && cfg.anonKey);
21
22  var client = null;   // supabase client (설정 시에만)
23  var session = null;  // 현재 세션
24  var profile = null;  // { role, nickname, ... }
25
26  // 번역 헬퍼 (localinfo.js 의 전역 t 사용, 없으면 폴백)
27  function T(key, fb) { return (typeof window.t === "function") ? window.t(key, fb) : fb; }
28
29  // ─── supabase-js 지연 로드 (설정된 경우에만) ───
30  function loadSupabaseJs() {
31    return new Promise(function (resolve, reject) {
32      if (window.supabase && window.supabase.createClient) return resolve(window.supabase);
33      var s = document.createElement("script");
34      s.src = SUPABASE_JS; s.async = true;
35      s.onload = function () { resolve(window.supabase); };
36      s.onerror = function () { reject(new Error("supabase-js 로드 실패")); };
37      document.head.appendChild(s);
38    });
39  }
40
41  // ─── 세션·프로필 조회 ───
42  function refresh() {
43    if (!client) return Promise.resolve();
44    return client.auth.getSession().then(function (r) {
45      session = (r && r.data && r.data.session) || null;
46      if (!session) { profile = null; render(); return; }
47      // profiles 행 조회 (트리거가 최초 로그인 시 자동 생성 — schema.sql handle_new_user)
48      return client.from("profiles").select("role,nickname").eq("id", session.user.id).single()
49        .then(function (p) { profile = (p && p.data) || null; render(); })
50        .catch(function () { profile = null; render(); });
51    });
52  }
53
54  // ─── 로그인 / 로그아웃 ───
55  function signIn(provider) {
56    if (!client) return;
57    client.auth.signInWithOAuth({
58      provider: provider,
59      options: { redirectTo: window.location.origin + window.location.pathname }
60    });
61  }
62  // 이메일/비밀번호 로그인 — 담당자·관리자용. 계정은 Supabase 대시보드(Authentication→Users)에서
63  // 수동 생성(자가 가입 폼 없음, admin 승급도 수동 원칙과 동일하게 계정 발급도 수동).
64  function signInEmail(email, password) {
65    if (!client) return Promise.reject(new Error("not configured"));
66    return client.auth.signInWithPassword({ email: email, password: password });
67  }
68  function signOut() {
69    if (!client) return;
70    client.auth.signOut().then(function () { session = null; profile = null; render(); });
71  }
72  window.SGAuth = {
73    signIn: signIn, signInEmail: signInEmail, signOut: signOut,
74    get session() { return session; }, get profile() { return profile; },
75    get client() { return client; },
76    get configured() { return configured; },
77    isSignedIn: function () { return !!(session && session.user); },
78    isReviewer: function () { return !!(profile && (profile.role === "reviewer" || profile.role === "admin")); },
79    isAdmin: function () { return !!(profile && profile.role === "admin"); },
80    openLogin: function () { openModal(); },
81    refresh: function () { return refresh(); },
82  };
83
84  // ─── UI ───
85  function providerButtons(list) {
86    return list.map(function (p) {
87      return '<button class="auth-provider auth-' + p + '" onclick="SGAuth.signIn(\'' + p + '\')">' +
88        esc(PROVIDER_LABEL[p] || p) + '</button>';
89    }).join("");
90  }
91
92  function renderModalBody() {
93    var m = document.getElementById("authModalBody");
94    if (!m) return;
95    if (!configured) {
96      m.innerHTML = '<p class="auth-pending">' +
97        esc(T("auth.pending", "로그인은 Supabase 설정 후 활성화됩니다. (backend/README 참조)")) + "</p>";
98      return;
99    }
100    m.innerHTML =
101      '<div class="auth-group"><div class="auth-group-h">' + esc(T("auth.grp.tourist", "관광객")) + "</div>" +
102        providerButtons(providers.tourist || []) + "</div>" +
103      '<div class="auth-group"><div class="auth-group-h">' + esc(T("auth.grp.gov", "담당자 · 관리자")) + "</div>" +
104        providerButtons(providers.gov || []) +
105        '<div class="auth-email-form">' +
106          '<input type="email" id="authEmailIn" class="submit-in" placeholder="' + esc(T("auth.email.ph", "이메일")) + '">' +
107          '<input type="password" id="authPwIn" class="submit-in" placeholder="' + esc(T("auth.pw.ph", "비밀번호")) + '">' +
108          '<button class="auth-provider auth-email" onclick="SGAuthEmailLogin()">' + esc(T("auth.email.login", "이메일 로그인")) + '</button>' +
109          '<p id="authEmailErr" class="auth-pending" style="display:none;"></p>' +
110        '</div>' +
111      "</div>" +
112      '<p class="auth-consent">' + esc(T("auth.consent", "로그인 시 개인정보 수집·이용 및 사진 활용에 동의합니다. (철회·삭제권 제공)")) + "</p>";
113  }
114
115  window.SGAuthEmailLogin = function () {
116    var emailIn = document.getElementById("authEmailIn");
117    var pwIn = document.getElementById("authPwIn");
118    var err = document.getElementById("authEmailErr");
119    if (!emailIn || !pwIn) return;
120    if (err) err.style.display = "none";
121    signInEmail(emailIn.value.trim(), pwIn.value).then(function (r) {
122      if (r.error) {
123        if (err) { err.textContent = r.error.message; err.style.display = ""; }
124        return;
125      }
126      closeModal();
127    });
128  };
129
130  function render() {
131    var btn = document.getElementById("authBtn");
132    if (!btn) return;
133    if (session && session.user) {
134      var name = (profile && profile.nickname) || (session.user.email || "").split("@")[0] || T("auth.member", "회원");
135      var role = (profile && profile.role) || "tourist";
136      var roleLabel = T("auth.role." + role, role);
137      btn.innerHTML = '<span class="auth-name">' + esc(name) + '</span>' +
138        '<span class="auth-role auth-role-' + esc(role) + '">' + esc(roleLabel) + "</span>";
139      btn.setAttribute("title", T("auth.logout", "로그아웃"));
140      btn.onclick = signOut;
141      btn.classList.add("auth-signed");
142      btn.disabled = false;
143    } else {
144      btn.textContent = T("auth.login", "로그인");
145      btn.onclick = openModal;
146      btn.classList.remove("auth-signed");
147      btn.disabled = false;                 // 미설정이어도 눌러서 안내 모달을 볼 수 있게
148      btn.classList.toggle("auth-disabled", !configured);
149    }
150    // 담당자·관리자 전용 진입점(검토 큐) 노출
151    var navRev = document.getElementById("navReview");
152    if (navRev) navRev.style.display = window.SGAuth.isReviewer() ? "" : "none";
153    var navAdm = document.getElementById("navAdmin");
154    if (navAdm) navAdm.style.display = window.SGAuth.isAdmin() ? "" : "none";
155    // 탭 노출/문구가 바뀌면 fixed nav 높이도 바뀔 수 있어 재계산(모바일 본문 가림 방지)
156    if (typeof window.syncNavHeight === "function") window.syncNavHeight();
157    renderModalBody();
158  }
159
160  function openModal() { var m = document.getElementById("authModal"); if (m) m.classList.add("open"); renderModalBody(); }
161  function closeModal() { var m = document.getElementById("authModal"); if (m) m.classList.remove("open"); }
162  window.closeAuthModal = closeModal;
163
164  // ─── 초기화 ───
165  function init() {
166    render(); // 버튼 상태 먼저 (미설정이면 여기서 끝)
167    if (!configured) return;
168    loadSupabaseJs().then(function (sb) {
169      client = sb.createClient(cfg.url, cfg.anonKey);
170      client.auth.onAuthStateChange(function () { refresh(); });
171      return refresh();
172    }).catch(function (e) {
173      // CDN 차단·오프라인 등 — 조용히 미설정처럼 처리
174      try { console.warn("[SGAuth]", e && e.message); } catch (x) {}
175      configured = false; render();
176    });
177  }
178
179  // 언어 변경 시 재렌더 (기존 핸들러 체이닝 — 덮어쓰기 방지)
180  (function () {
181    var prev = window.onLangChange;
182    window.onLangChange = function (lang) {
183      if (typeof prev === "function") { try { prev(lang); } catch (e) {} }
184      render();
185    };
186  })();
187
188  document.addEventListener("DOMContentLoaded", init);
189})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.