const TOKEN_KEYS = { access: 'access_token', refresh: 'refresh_token', preAuth: 'pre_auth_token', }; function getStoredToken(key) { return localStorage.getItem(key) || sessionStorage.getItem(key); } function setTokens(accessToken, refreshToken) { localStorage.setItem(TOKEN_KEYS.access, accessToken); if (refreshToken) { localStorage.setItem(TOKEN_KEYS.refresh, refreshToken); } sessionStorage.removeItem(TOKEN_KEYS.preAuth); } function clearTokens() { Object.values(TOKEN_KEYS).forEach((key) => { localStorage.removeItem(key); sessionStorage.removeItem(key); }); _meCache = null; } function loginRedirectUrl(nextPath) { const next = encodeURIComponent(nextPath || (window.location.pathname + window.location.search)); return `/login?next=${next}`; } function logout() { clearTokens(); fetch('/api/v1/auth/logout', { method: 'POST' }).catch(() => {}); window.location.href = '/login'; } let _refreshPromise = null; async function tryRefreshToken() { const refreshToken = getStoredToken(TOKEN_KEYS.refresh); if (!refreshToken) return false; if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { try { const res = await fetch('/api/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data = await res.json(); setTokens(data.access_token, data.refresh_token); return true; } catch { return false; } finally { _refreshPromise = null; } })(); return _refreshPromise; } async function readErrorDetail(res) { try { const body = await res.clone().json(); return typeof body.detail === 'string' ? body.detail : ''; } catch { return ''; } } async function fetchWithAuth(url, options = {}) { const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${getStoredToken(TOKEN_KEYS.access)}`, ...options.headers, }; let res = await fetch(url, { ...options, headers }); if (res.status === 401) { const detail = await readErrorDetail(res); if (detail === '2FA setup required') { clearTokens(); window.location.href = loginRedirectUrl('/setup-2fa'); throw new Error('auth'); } const refreshed = await tryRefreshToken(); if (refreshed) { res = await fetch(url, { ...options, headers: { ...headers, Authorization: `Bearer ${getStoredToken(TOKEN_KEYS.access)}`, }, }); } if (res.status === 401) { clearTokens(); window.location.href = loginRedirectUrl(); throw new Error('auth'); } } return res; } const API = { token: () => getStoredToken(TOKEN_KEYS.access), headers: () => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${API.token()}`, }), async get(url) { const res = await fetchWithAuth(url, { headers: API.headers() }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || `GET failed (${res.status})`); } return res.json(); }, async post(url, body) { const res = await fetchWithAuth(url, { method: 'POST', headers: API.headers(), body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); const detail = err.detail; const msg = typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map((d) => d.msg).join(', ') : `POST failed (${res.status})`; throw new Error(msg); } return res.json(); }, async patch(url, body) { const res = await fetchWithAuth(url, { method: 'PATCH', headers: API.headers(), body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); const detail = err.detail; const msg = typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map((d) => d.msg || String(d)).join(', ') : `PATCH failed (${res.status})`; throw new Error(msg); } return res.json(); }, async delete(url) { const res = await fetchWithAuth(url, { method: 'DELETE', headers: API.headers(), }); if (!res.ok) { const err = await res.json().catch(() => ({})); const detail = err.detail; const msg = typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map((d) => d.msg || String(d)).join(', ') : `DELETE failed (${res.status})`; throw new Error(msg); } if (res.status === 204) return null; return res.json().catch(() => null); }, async download(url, filename) { const res = await fetchWithAuth(url, { headers: { Authorization: `Bearer ${API.token()}` }, }); if (!res.ok) { const err = await res.json().catch(() => ({})); const detail = err.detail; const msg = typeof detail === 'string' ? detail : `다운로드 실패 (${res.status})`; throw new Error(msg); } const contentType = res.headers.get('Content-Type') || ''; let blob; if (contentType.includes('text/') || contentType.includes('csv')) { const text = await res.text(); const body = text.startsWith('\ufeff') ? text : `\ufeff${text}`; blob = new Blob([body], { type: 'text/csv;charset=utf-8' }); } else { blob = await res.blob(); } const href = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = href; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(href); }, }; const CatalogStore = { KEY: 'staysync_catalog', load() { try { return JSON.parse(localStorage.getItem(this.KEY) || '{}'); } catch { return {}; } }, save(companyId, propertyGroupId) { localStorage.setItem(this.KEY, JSON.stringify({ companyId, propertyGroupId })); }, }; async function initCatalogSelectors({ onPropertyChange, skipPropertyLoad } = {}) { const companies = await API.get('/api/v1/companies'); const companySel = document.getElementById('companySelect'); const propertySel = document.getElementById('propertySelect'); const saved = CatalogStore.load(); companySel.innerHTML = companies.map((c) => ``).join(''); companySel.classList.add('hidden'); companySel.hidden = true; let companyId = saved.companyId && companies.some((c) => c.id === saved.companyId) ? saved.companyId : companies[0]?.id; async function loadPropertyGroups() { if (!companyId) { propertySel.innerHTML = ''; if (!skipPropertyLoad && onPropertyChange) { await onPropertyChange(null, []); } return null; } const groups = await API.get(`/api/v1/property-groups?company_id=${companyId}`); propertySel.innerHTML = groups.map((g) => { const suffix = g.status && g.status !== 'active' ? ` [${g.status}]` : ''; return ``; }).join(''); let propertyGroupId = saved.propertyGroupId && groups.some((g) => g.id === saved.propertyGroupId) ? saved.propertyGroupId : groups[0]?.id; if (propertyGroupId) propertySel.value = propertyGroupId; CatalogStore.save(companyId, propertyGroupId); if (!skipPropertyLoad && onPropertyChange) { await onPropertyChange(propertyGroupId, groups); } return { companyId, propertyGroupId, companies, groups }; } if (companyId) companySel.value = companyId; companySel.addEventListener('change', async () => { companyId = companySel.value; saved.propertyGroupId = null; await loadPropertyGroups(); }); propertySel.addEventListener('change', async () => { const propertyGroupId = propertySel.value; CatalogStore.save(companyId, propertyGroupId); if (onPropertyChange) { const groups = companyId ? await API.get(`/api/v1/property-groups?company_id=${companyId}`) : []; await onPropertyChange(propertyGroupId, groups); } }); return loadPropertyGroups(); } function requireAuth() { if (!API.token()) { window.location.href = loginRedirectUrl(); return false; } return true; } let _meCache = null; async function getMe(force = false) { if (_meCache && !force) return _meCache; _meCache = await API.get('/api/v1/users/me'); return _meCache; } function wireAuthNav(me) { const usernameEl = document.getElementById('navUsername'); const logoutBtn = document.getElementById('logoutBtn'); if (usernameEl && me) { usernameEl.textContent = me.username; } if (logoutBtn && !logoutBtn.dataset.wired) { logoutBtn.dataset.wired = '1'; logoutBtn.addEventListener('click', () => logout()); } } async function applyAdminNav({ requireAdmin = false } = {}) { const me = await getMe(); wireAuthNav(me); const isAdmin = me.role === 'admin'; document.querySelectorAll('[data-admin-only]').forEach((el) => { el.classList.toggle('hidden', !isAdmin); }); if (requireAdmin && !isAdmin) { window.location.href = '/'; return null; } return me; } /** Parse API UTC ISO string and format in Korea local time. */ function parseUtcIso(iso) { if (!iso) return null; const s = String(iso).trim(); if (/[Zz]$/.test(s) || /[+-]\d{2}:\d{2}$/.test(s)) { return new Date(s); } return new Date(`${s}Z`); } function formatUtcDateTime(iso, options = {}) { const d = parseUtcIso(iso); if (!d || Number.isNaN(d.getTime())) return '—'; return d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', ...options, }); }