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,
});
}