PageSourceSearch

https://analytics-tracking-admin.vercel.app/js/auth.js

js analytics-tracking-admin.vercel.app collected 2026-10-03 06:15:53 UTC 9,660 bytes, 158 lines download raw bytes

1// auth.js — 로그인 + 역할
2// 데모모드: 역할 선택으로 로그인 시뮬 (마스터/관리자/고객 화면 차이를 그대로 체험)
3// 라이브모드: Supabase 이메일+비밀번호 → accounts 행에서 역할 조회. 이어서 구글 OTP 2단계 인증 — 마스터 필수·그 외 권유(mfa.js, 2026-09-19).
4import { IS_DEMO, SUPABASE, APP_NAME } from './config.js';
5import { esc } from './util.js';
6import { setAuthToken } from './data.js';
7import { checkBeforeLogin, recordFail, recordSuccess, humanWait } from './loginguard.js';
8import { mfaGate } from './mfa.js';   // 2단계 인증: 마스터 필수 · 그 외 권유 (2026-09-19)
9import { SCOPE } from './scope.js';   // 전용 콘솔이면 제목만 바꾼다 (2026-09-17)
10
11export const ROLES = {
12  master:        { label: '마스터(대표)',  desc: '전체 열람(가명)·계정/단가/파기 관리' },
13  manager:       { label: '관리자',        desc: '가명 DB·유입경로·셋업, 자기 업체만' },
14  client:        { label: '고객(광고주)',  desc: '자기 업체 DB(원본)·팀관리·DB 분배' },
15  client_member: { label: '고객 팀원',     desc: '배정받은 DB만 (원본)' },
16};
17
18export function currentUser() {
19  try { return JSON.parse(sessionStorage.getItem('dbs_user')) || null; } catch { return null; }
20}
21export function logout() { sessionStorage.removeItem('dbs_user'); location.reload(); }
22
23// 비밀번호 설정 링크(recovery)로 진입한 경우: 새 비밀번호 등록 화면 (2026-08-27)
24// 관리자가 발급한 링크 클릭 → #access_token...type=recovery 로 도착 → 여기서 새 비번 저장
25function handleRecovery(el) {
26  const h = new URLSearchParams(location.hash.slice(1));
27  if (h.get('type') !== 'recovery' || !h.get('access_token')) return false;
28  const token = h.get('access_token');
29  el.innerHTML = `
30    <div class="login-card">
31      <h2>${esc(SCOPE ? SCOPE.appName : APP_NAME)}</h2>
32      <p class="muted">새 비밀번호를 설정하세요.</p>
33      <input id="rc-pw" type="password" placeholder="새 비밀번호 (8자 이상)" style="width:100%;margin-bottom:8px">
34      <input id="rc-pw2" type="password" placeholder="비밀번호 확인" style="width:100%;margin-bottom:12px">
35      <button id="rc-save" class="primary" style="width:100%">비밀번호 설정</button>
36      <p id="rc-err" style="color:#d62b1f;font-size:13px;margin-top:8px"></p>
37    </div>`;
38  el.querySelector('#rc-save').addEventListener('click', async () => {
39    const pw = el.querySelector('#rc-pw').value, pw2 = el.querySelector('#rc-pw2').value;
40    const err = el.querySelector('#rc-err');
41    if (pw.length < 8) { err.textContent = '8자 이상으로 정해주세요.'; return; }
42    if (pw !== pw2) { err.textContent = '두 입력이 서로 다릅니다.'; return; }
43    try {
44      const res = await fetch(SUPABASE.url + '/auth/v1/user', {
45        method: 'PUT',
46        headers: { apikey: SUPABASE.anonKey, Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
47        body: JSON.stringify({ password: pw }),
48      });
49      if (!res.ok) throw new Error((await res.json()).msg || '설정 실패');
50      history.replaceState(null, '', location.pathname);
51      alert('비밀번호가 설정되었습니다. 새 비밀번호로 로그인하세요.');
52      location.reload();
53    } catch (e) { err.textContent = String(e.message || e); }
54  });
55  return true;
56}
57
58// 로그인 화면을 그리고, 성공 시 onLogin(user) 호출
59export function renderLogin(el, onLogin) {
60  if (!IS_DEMO && handleRecovery(el)) return;
61  if (IS_DEMO) {
62    el.innerHTML = `
63      <div class="login-card">
64        <h2>${esc(SCOPE ? SCOPE.appName : APP_NAME)}</h2>
65        <p class="muted">데모모드 — 역할을 골라 화면 차이를 확인하세요.<br>(config.js에 Supabase 키를 넣으면 실계정 로그인으로 바뀝니다)</p>
66        <div class="role-btns">
67          <button data-role="master" data-acc="acc-boss">👑 마스터(대표)</button>
68          <button data-role="manager" data-co="co-1" data-acc="acc-mgr">🧑‍💼 관리자 — 한결법무 담당</button>
69          <button data-role="client" data-co="co-1" data-acc="acc-client">🤝 고객(광고주 팀장) — 한결법무</button>
70          <button data-role="client_member" data-co="co-1" data-acc="acc-m1">👤 고객 팀원 — 상담원A (배정분만)</button>
71        </div>
72        <p class="muted" style="font-size:11px">마스터·관리자는 이름·연락처가 <b>가명</b>으로, 고객은 자기 업체 <b>원본</b>으로 보입니다 (요구사항서 기준).</p>
73      </div>`;
74    el.querySelectorAll('button[data-role]').forEach(b => b.addEventListener('click', () => {
75      const user = { role: b.dataset.role, company_id: b.dataset.co || null, account_id: b.dataset.acc || null, email: b.dataset.role + '@demo', name: b.textContent.trim() };
76      sessionStorage.setItem('dbs_user', JSON.stringify(user));
77      onLogin(user);
78    }));
79    return;
80  }
81  // ── 라이브모드 로그인 ──
82  el.innerHTML = `
83    <div class="login-card">
84      <h2>${esc(SCOPE ? SCOPE.appName : APP_NAME)}</h2>
85      <input id="li-email" type="email" placeholder="이메일" autocomplete="username">
86      <input id="li-pw" type="password" placeholder="비밀번호" autocomplete="current-password">
87      <button id="li-btn" class="primary">로그인</button>
88      <div id="li-err" class="err"></div>
89    </div>`;
90  // 엔터로 제출
91  el.querySelectorAll('#li-email, #li-pw').forEach(i => i.addEventListener('keydown', e => {
92    if (e.key === 'Enter') el.querySelector('#li-btn').click();
93  }));
94  el.querySelector('#li-btn').addEventListener('click', async () => {
95    const err = el.querySelector('#li-err');
96    const btn = el.querySelector('#li-btn');
97    err.textContent = '';
98    const email = el.querySelector('#li-email').value.trim();
99    try {
100      // 잠금 확인 (2026-09-16) — 5회↑ 10분 / 10회↑ 1시간 / 15회↑ 10시간
101      if (email) {
102        const g = await checkBeforeLogin(email);
103        if (g.locked) {
104          err.textContent = `로그인이 잠겼습니다. ${humanWait(g.wait_sec)} 후 다시 시도하세요. (실패 ${g.fails}회)`;
105          return;
106        }
107      }
108      btn.disabled = true;
109      const res = await fetch(SUPABASE.url + '/auth/v1/token?grant_type=password', {
110        method: 'POST',
111        headers: { apikey: SUPABASE.anonKey, 'Content-Type': 'application/json' },
112        body: JSON.stringify({ email, password: el.querySelector('#li-pw').value }),
113      });
114      const out = await res.json();
115      if (!res.ok) {
116        // 비밀번호 오류 → 실패 1회 기록하고, 다음 단계 잠금이면 그 사실을 알려준다
117        const st = await recordFail(email);
118        const base = out.error_description || out.msg || '로그인 실패';
119        throw new Error(st.locked ? `${base} — 실패 ${st.fails}회로 ${humanWait(st.wait_sec)} 동안 잠겼습니다.`
120                                  : `${base} (실패 ${st.fails}회)`);
121      }
122      setAuthToken(out.access_token);
123      await recordSuccess(email, out.access_token);   // 성공 시 카운터 리셋
124      const rows = await fetch(SUPABASE.url + '/rest/v1/accounts?select=*&auth_id=eq.' + out.user.id, {
125        headers: { apikey: SUPABASE.anonKey, Authorization: 'Bearer ' + out.access_token },
126      }).then(r => r.json());
127      const acc = rows && rows[0];
128      if (!acc || !acc.active) throw new Error('등록되지 않았거나 비활성화된 계정입니다.');
129      // 보안: 역할 화이트리스트 — 미상 역할은 최소권한(client_member)
130      const role = ['master', 'manager', 'client', 'client_member'].includes(acc.role) ? acc.role : 'client_member';
131      // 2단계 인증 (2026-09-19) — 마스터는 필수, 그 외는 권유(하루 동안 보지 않기 가능). 등록한 사람은 6자리를 통과해야 들어간다.
132      //   통과하면 "2단계 통과" 도장이 찍힌 새 토큰을 받는다. 권유를 넘기면 s2 = null → 지금 토큰 그대로 입장.
133      const s2 = await mfaGate(el, out, email, role);
134      if (s2) {
135        out.access_token = s2.access_token;
136        out.refresh_token = s2.refresh_token || out.refresh_token;
137        out.expires_in = s2.expires_in || out.expires_in;
138        setAuthToken(out.access_token);
139      }
140      const user = {
141        role, company_id: acc.company_id, account_id: acc.id, email: acc.email, name: acc.name,
142        // 다중 담당(2026-08-26): company_ids 배열 우선, 없으면 단일 company_id 폴백
143        company_ids: (acc.company_ids && acc.company_ids.length ? acc.company_ids : (acc.company_id ? [acc.company_id] : [])),
144        token: out.access_token,
145        refresh_token: out.refresh_token,                          // JWT 자동 갱신용
146        expires_at: Date.now() + (out.expires_in || 3600) * 1000,  // 만료 시각
147      };
148      sessionStorage.setItem('dbs_user', JSON.stringify(user));
149      onLogin(user);
150    } catch (e) {
151      // 2단계 화면으로 넘어간 뒤 난 오류면 로그인 칸이 이미 사라졌다 → 로그인 화면을 다시 그리고 알린다
152      if (!err.isConnected) { renderLogin(el, onLogin); const e2 = el.querySelector('#li-err'); if (e2) e2.textContent = String(e.message || e); }
153      else err.textContent = String(e.message || e);
154    } finally {
155      const b = el.querySelector('#li-btn'); if (b) b.disabled = false;
156    }
157  });
158}

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.