- Add bottle_size field to User model and UserResponse/UserUpdate schemas - Settings modal includes bottle size input (shots capacity) - Community bottles and My Bottle page show fill bar based on bottle size - Community bottle cards are clickable — opens searchable bourbon list modal - Add total_shots_added stat to replace duplicate net volume on dashboard - Reorder dashboard stats: Bourbons Added, Total Poured In, Shots Remaining, Est. Proof - Theme-matched custom scrollbar (amber on dark) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.7 KiB
JavaScript
247 lines
9.7 KiB
JavaScript
/* Auth state helpers shared across all pages */
|
|
|
|
const Auth = (() => {
|
|
const KEY = 'bb_token';
|
|
const USER_KEY = 'bb_user';
|
|
|
|
function getToken() { return localStorage.getItem(KEY); }
|
|
function saveToken(token) { localStorage.setItem(KEY, token); }
|
|
|
|
function getUser() {
|
|
const raw = localStorage.getItem(USER_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
}
|
|
|
|
function saveUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); }
|
|
|
|
function _decodePayload() {
|
|
const token = getToken();
|
|
if (!token) return null;
|
|
try { return JSON.parse(atob(token.split('.')[1])); } catch (_) { return null; }
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem(KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
window.location.href = '/login.html';
|
|
}
|
|
|
|
function isLoggedIn() { return !!getToken(); }
|
|
|
|
function requireAuth() {
|
|
if (!isLoggedIn()) {
|
|
window.location.href = '/login.html';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function redirectIfLoggedIn() {
|
|
if (isLoggedIn()) window.location.href = '/dashboard.html';
|
|
}
|
|
|
|
async function renderNav(activePage) {
|
|
const navLinksEl = document.getElementById('nav-links');
|
|
const navUserEl = document.getElementById('nav-user');
|
|
if (!navLinksEl || !navUserEl) return;
|
|
|
|
if (isLoggedIn()) {
|
|
let user = getUser();
|
|
if (!user) {
|
|
try { user = await API.users.me(); saveUser(user); } catch (_) {}
|
|
}
|
|
|
|
const payload = _decodePayload();
|
|
const isImpersonating = !!(payload && payload.admin_id);
|
|
|
|
navLinksEl.innerHTML = `
|
|
<a href="/dashboard.html" class="btn btn-ghost btn-sm${activePage === 'dashboard' ? ' active' : ''}">My Bottle</a>
|
|
<a href="/log.html" class="btn btn-ghost btn-sm${activePage === 'log' ? ' active' : ''}">Log Entry</a>
|
|
`;
|
|
|
|
if (isImpersonating) {
|
|
navUserEl.innerHTML = `
|
|
<span class="nav-impersonating">Viewing as <strong>${escHtml(user?.display_name || user?.email || 'User')}</strong></span>
|
|
<button onclick="Auth.returnToAdmin()" class="btn btn-sm btn-amber">↩ Return to Admin</button>
|
|
`;
|
|
} else {
|
|
navUserEl.innerHTML = `
|
|
<span class="nav-username">${escHtml(user?.display_name || user?.email || 'Account')}</span>
|
|
${user?.is_admin ? '<a href="/admin.html" class="btn btn-sm btn-ghost">Admin</a>' : ''}
|
|
<button onclick="Auth.showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">⚙</button>
|
|
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
|
|
`;
|
|
_injectSettingsModal(user);
|
|
}
|
|
} else {
|
|
navLinksEl.innerHTML = '';
|
|
navUserEl.innerHTML = `<a href="/login.html" class="btn btn-primary btn-sm">Login</a>`;
|
|
}
|
|
}
|
|
|
|
function _buildTimezoneOptions(selected) {
|
|
let allTz;
|
|
try {
|
|
allTz = Intl.supportedValuesOf('timeZone');
|
|
} catch (_) {
|
|
allTz = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
|
|
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
|
|
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo',
|
|
'Asia/Shanghai', 'Australia/Sydney'];
|
|
}
|
|
const groups = {};
|
|
for (const tz of allTz) {
|
|
const slash = tz.indexOf('/');
|
|
const group = slash === -1 ? 'Other' : tz.slice(0, slash);
|
|
(groups[group] = groups[group] || []).push(tz);
|
|
}
|
|
return Object.keys(groups).sort().map(group => {
|
|
const opts = groups[group].map(tz => {
|
|
const label = tz.slice(tz.indexOf('/') + 1).replace(/_/g, ' ').replace(/\//g, ' / ');
|
|
return `<option value="${tz}"${tz === selected ? ' selected' : ''}>${label}</option>`;
|
|
}).join('');
|
|
return `<optgroup label="${group}">${opts}</optgroup>`;
|
|
}).join('');
|
|
}
|
|
|
|
function _injectSettingsModal(user) {
|
|
if (document.getElementById('settings-modal')) return;
|
|
const tzOptions = _buildTimezoneOptions(user?.timezone || 'UTC');
|
|
document.body.insertAdjacentHTML('beforeend', `
|
|
<div id="settings-modal" class="modal-overlay" style="display:none">
|
|
<div class="modal-box">
|
|
<h2>Settings</h2>
|
|
<div id="settings-msg"></div>
|
|
|
|
<h3 class="settings-section-title">Profile</h3>
|
|
<div class="form-group">
|
|
<label for="settings-display-name">Display Name</label>
|
|
<input type="text" id="settings-display-name" value="${escHtml(user?.display_name || '')}" maxlength="100" />
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:1.25rem">
|
|
<label for="settings-bottle-size">Bottle Size (shots)</label>
|
|
<input type="number" id="settings-bottle-size" value="${user?.bottle_size ?? ''}" min="1" step="0.5" placeholder="e.g. 25" />
|
|
</div>
|
|
|
|
<hr class="settings-divider">
|
|
|
|
<h3 class="settings-section-title">Timezone</h3>
|
|
<div class="form-group" style="margin-bottom:0.5rem">
|
|
<label for="settings-tz">Your timezone</label>
|
|
<select id="settings-tz">${tzOptions}</select>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.25rem">
|
|
<button class="btn btn-sm btn-ghost" onclick="Auth.detectTimezone()">Detect automatically</button>
|
|
<button class="btn btn-primary btn-sm" onclick="Auth.saveProfile()">Save Profile & Timezone</button>
|
|
</div>
|
|
|
|
<hr class="settings-divider">
|
|
|
|
<h3 class="settings-section-title">Change Password</h3>
|
|
<div class="form-group" style="margin-bottom:0.75rem">
|
|
<label>Current Password</label>
|
|
<input type="password" id="settings-pw-current" autocomplete="current-password" />
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0.75rem">
|
|
<label>New Password</label>
|
|
<input type="password" id="settings-pw-new" autocomplete="new-password" />
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:1rem">
|
|
<label>Confirm New Password</label>
|
|
<input type="password" id="settings-pw-confirm" autocomplete="new-password" />
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
|
<button class="btn btn-ghost" onclick="Auth.hideSettingsModal()">Close</button>
|
|
<button class="btn btn-primary" onclick="Auth.submitPasswordChange()">Update Password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.id === 'settings-modal') hideSettingsModal();
|
|
});
|
|
}
|
|
|
|
function showSettingsModal() {
|
|
document.getElementById('settings-modal').style.display = 'flex';
|
|
const msg = document.getElementById('settings-msg');
|
|
if (msg) msg.innerHTML = '';
|
|
document.getElementById('settings-pw-current').value = '';
|
|
document.getElementById('settings-pw-new').value = '';
|
|
document.getElementById('settings-pw-confirm').value = '';
|
|
}
|
|
|
|
function hideSettingsModal() {
|
|
document.getElementById('settings-modal').style.display = 'none';
|
|
}
|
|
|
|
function detectTimezone() {
|
|
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const sel = document.getElementById('settings-tz');
|
|
if (sel) sel.value = detected;
|
|
}
|
|
|
|
async function saveProfile() {
|
|
const displayName = document.getElementById('settings-display-name').value.trim();
|
|
const timezone = document.getElementById('settings-tz').value;
|
|
const bottleSizeRaw = document.getElementById('settings-bottle-size').value;
|
|
const bottleSize = bottleSizeRaw !== '' ? parseFloat(bottleSizeRaw) : null;
|
|
try {
|
|
const user = await API.users.update({ display_name: displayName || null, timezone, bottle_size: bottleSize });
|
|
saveUser(user);
|
|
const usernameEl = document.querySelector('.nav-username');
|
|
if (usernameEl) usernameEl.textContent = user.display_name || user.email || 'Account';
|
|
_showSettingsMsg('Profile saved.', 'success');
|
|
} catch (err) {
|
|
_showSettingsMsg(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function submitPasswordChange() {
|
|
const current = document.getElementById('settings-pw-current').value;
|
|
const newPw = document.getElementById('settings-pw-new').value;
|
|
const confirm = document.getElementById('settings-pw-confirm').value;
|
|
|
|
if (newPw !== confirm) { _showSettingsMsg('New passwords do not match.', 'error'); return; }
|
|
if (newPw.length < 8) { _showSettingsMsg('Password must be at least 8 characters.', 'error'); return; }
|
|
|
|
try {
|
|
await API.users.changePassword({ current_password: current, new_password: newPw });
|
|
_showSettingsMsg('Password updated!', 'success');
|
|
document.getElementById('settings-pw-current').value = '';
|
|
document.getElementById('settings-pw-new').value = '';
|
|
document.getElementById('settings-pw-confirm').value = '';
|
|
} catch (err) {
|
|
_showSettingsMsg(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function _showSettingsMsg(text, type) {
|
|
const el = document.getElementById('settings-msg');
|
|
if (!el) return;
|
|
const cls = type === 'success' ? 'alert-success' : 'alert-error';
|
|
el.innerHTML = `<div class="alert ${cls}" style="margin-bottom:1rem">${escHtml(text)}</div>`;
|
|
if (type === 'success') setTimeout(() => { el.innerHTML = ''; }, 3000);
|
|
}
|
|
|
|
async function returnToAdmin() {
|
|
try {
|
|
const data = await API.post('/admin/unimpersonate', {});
|
|
saveToken(data.access_token);
|
|
const user = await API.users.me();
|
|
saveUser(user);
|
|
window.location.href = '/admin.html';
|
|
} catch (_) {
|
|
logout();
|
|
}
|
|
}
|
|
|
|
return {
|
|
getToken, saveToken, getUser, saveUser,
|
|
logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav,
|
|
showSettingsModal, hideSettingsModal, detectTimezone, saveProfile, submitPasswordChange,
|
|
returnToAdmin,
|
|
};
|
|
})();
|