Add multi-user auth, admin panel, and timezone support; rename to Yolkbook
- Rename app from Eggtracker to Yolkbook throughout - Add JWT-based authentication (python-jose, passlib/bcrypt) - Add users table; all data tables gain user_id FK for full data isolation - Super admin credentials sourced from ADMIN_USERNAME/ADMIN_PASSWORD env vars, synced on every startup; orphaned rows auto-assigned to admin post-migration - Login page with self-registration; JWT stored in localStorage (30-day expiry) - Admin panel (/admin): list users, reset passwords, disable/enable, delete, and impersonate (Login As) with Return to Admin banner - Settings modal (gear icon in nav): timezone selector and change password - Timezone stored per-user; stats date windows computed in user's timezone; date input setToday() respects user timezone via Intl API - migrate_v2.sql for existing single-user installs - Auto-migration adds timezone column to users on startup - Updated README with full setup, auth, admin, and migration docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 Not Found — Eggtracker</title>
|
||||
<title>404 Not Found — Yolkbook</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||
@@ -13,7 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
||||
<a class="nav-brand" href="/">🥚 Yolkbook</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Error — Eggtracker</title>
|
||||
<title>Server Error — Yolkbook</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||
@@ -13,7 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
||||
<a class="nav-brand" href="/">🥚 Yolkbook</a>
|
||||
</nav>
|
||||
<main class="container">
|
||||
<div class="error-center">
|
||||
|
||||
85
nginx/html/admin.html
Normal file
85
nginx/html/admin.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
<li><a href="/history">History</a></li>
|
||||
<li><a href="/flock">Flock</a></li>
|
||||
<li><a href="/budget">Budget</a></li>
|
||||
<li><a href="/summary">Summary</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<h1>Admin — User Management</h1>
|
||||
|
||||
<div id="msg" class="message"></div>
|
||||
|
||||
<!-- User list -->
|
||||
<div class="section-header">
|
||||
<h2>All Users</h2>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadUsers()">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-body">
|
||||
<tr class="empty-row"><td colspan="5">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Reset password modal -->
|
||||
<div id="reset-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h2>Reset Password</h2>
|
||||
<p style="margin-bottom:1rem;color:var(--muted)">Setting new password for: <strong id="reset-username"></strong></p>
|
||||
<div id="reset-msg" class="message"></div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="reset-password" minlength="6" placeholder="min 6 characters">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="hideResetModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitReset()">Set Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<div id="delete-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h2>Delete User</h2>
|
||||
<p style="margin-bottom:1.5rem">Delete <strong id="delete-username"></strong>? This will permanently remove their account and all associated data.</p>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="hideDeleteModal()">Cancel</button>
|
||||
<button class="btn btn-danger" onclick="submitDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/admin.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Budget — Eggtracker</title>
|
||||
<title>Budget — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -123,7 +123,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/budget.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -173,6 +173,7 @@ label { font-size: 0.875rem; font-weight: 500; }
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -259,4 +260,120 @@ td input[type="date"] {
|
||||
.nav-links { overflow-x: auto; scrollbar-width: none; }
|
||||
.nav-links::-webkit-scrollbar { display: none; }
|
||||
.nav-links a { padding: 0.4rem 0.55rem; font-size: 0.82rem; white-space: nowrap; }
|
||||
.nav-username { display: none; }
|
||||
}
|
||||
|
||||
/* ── Nav user section ─────────────────────────────────────────────────────── */
|
||||
.nav-user {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-username {
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-impersonating {
|
||||
color: #ffe08a;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-admin-btn {
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
border-color: rgba(255,255,255,0.35) !important;
|
||||
}
|
||||
.nav-admin-btn:hover {
|
||||
background: rgba(255,255,255,0.15) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-amber { background: var(--amber); color: #fff; }
|
||||
.btn-amber:hover { background: #b8720a; }
|
||||
|
||||
/* ── Login page ───────────────────────────────────────────────────────────── */
|
||||
.login-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card { padding: 2rem; }
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Modals ───────────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
.modal-box h2 { margin-bottom: 1rem; }
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-admin { background: #d4edff; color: #0055aa; }
|
||||
.badge-user { background: #e8f5e9; color: #2e6b3e; }
|
||||
.badge-active { background: #d4edda; color: #155724; }
|
||||
.badge-disabled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* ── Settings modal extras ────────────────────────────────────────────────── */
|
||||
.settings-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--green-dark);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flock — Eggtracker</title>
|
||||
<title>Flock — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -80,7 +80,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/flock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>History — Eggtracker</title>
|
||||
<title>History — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -63,7 +63,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard — Eggtracker</title>
|
||||
<title>Dashboard — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -73,7 +73,8 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/dashboard.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
157
nginx/html/js/admin.js
Normal file
157
nginx/html/js/admin.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// admin.js — admin user management page
|
||||
|
||||
let resetTargetId = null;
|
||||
let deleteTargetId = null;
|
||||
let currentAdminId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Verify admin access
|
||||
const user = Auth.getUser();
|
||||
if (!user || !user.is_admin) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
currentAdminId = parseInt(user.sub);
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await API.get('/api/admin/users');
|
||||
renderUsers(users);
|
||||
} catch (err) {
|
||||
showMessage(document.getElementById('msg'), err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const tbody = document.getElementById('users-body');
|
||||
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">No users found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = users.map(u => {
|
||||
const isSelf = u.id === currentAdminId;
|
||||
const roleLabel = u.is_admin
|
||||
? '<span class="badge badge-admin">Admin</span>'
|
||||
: '<span class="badge badge-user">User</span>';
|
||||
const statusLabel = u.is_disabled
|
||||
? '<span class="badge badge-disabled">Disabled</span>'
|
||||
: '<span class="badge badge-active">Active</span>';
|
||||
const created = new Date(u.created_at).toLocaleDateString();
|
||||
|
||||
const toggleBtn = u.is_disabled
|
||||
? `<button class="btn btn-sm btn-primary" onclick="toggleUser(${u.id}, false)">Enable</button>`
|
||||
: `<button class="btn btn-sm btn-ghost" onclick="toggleUser(${u.id}, true)" ${isSelf ? 'disabled title="Cannot disable yourself"' : ''}>Disable</button>`;
|
||||
|
||||
const impersonateBtn = !isSelf
|
||||
? `<button class="btn btn-sm btn-ghost" onclick="impersonateUser(${u.id}, '${u.username}')">Login As</button>`
|
||||
: '';
|
||||
|
||||
const deleteBtn = !isSelf
|
||||
? `<button class="btn btn-sm btn-danger" onclick="showDeleteModal(${u.id}, '${u.username}')">Delete</button>`
|
||||
: '';
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${u.username}</strong></td>
|
||||
<td>${roleLabel}</td>
|
||||
<td>${statusLabel}</td>
|
||||
<td>${created}</td>
|
||||
<td class="actions" style="display:flex;gap:0.35rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-ghost" onclick="showResetModal(${u.id}, '${u.username}')">Reset PW</button>
|
||||
${toggleBtn}
|
||||
${impersonateBtn}
|
||||
${deleteBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
function showResetModal(id, username) {
|
||||
resetTargetId = id;
|
||||
document.getElementById('reset-username').textContent = username;
|
||||
document.getElementById('reset-password').value = '';
|
||||
document.getElementById('reset-msg').className = 'message';
|
||||
document.getElementById('reset-modal').style.display = 'flex';
|
||||
document.getElementById('reset-password').focus();
|
||||
}
|
||||
|
||||
function hideResetModal() {
|
||||
document.getElementById('reset-modal').style.display = 'none';
|
||||
resetTargetId = null;
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
const password = document.getElementById('reset-password').value;
|
||||
const msgEl = document.getElementById('reset-msg');
|
||||
|
||||
if (password.length < 6) {
|
||||
msgEl.textContent = 'Password must be at least 6 characters';
|
||||
msgEl.className = 'message error visible';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.post(`/api/admin/users/${resetTargetId}/reset-password`, { new_password: password });
|
||||
msgEl.textContent = 'Password reset successfully.';
|
||||
msgEl.className = 'message success visible';
|
||||
setTimeout(hideResetModal, 1200);
|
||||
} catch (err) {
|
||||
msgEl.textContent = err.message;
|
||||
msgEl.className = 'message error visible';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUser(id, disable) {
|
||||
const action = disable ? 'disable' : 'enable';
|
||||
try {
|
||||
await API.post(`/api/admin/users/${id}/${action}`, {});
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
showMessage(document.getElementById('msg'), err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function impersonateUser(id, username) {
|
||||
try {
|
||||
const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
|
||||
// Save admin token so user can return
|
||||
sessionStorage.setItem('admin_token', Auth.getToken());
|
||||
Auth.setToken(data.access_token);
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
showMessage(document.getElementById('msg'), err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showDeleteModal(id, username) {
|
||||
deleteTargetId = id;
|
||||
document.getElementById('delete-username').textContent = username;
|
||||
document.getElementById('delete-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
deleteTargetId = null;
|
||||
}
|
||||
|
||||
async function submitDelete() {
|
||||
try {
|
||||
await API.del(`/api/admin/users/${deleteTargetId}`);
|
||||
hideDeleteModal();
|
||||
showMessage(document.getElementById('msg'), 'User deleted.');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
hideDeleteModal();
|
||||
showMessage(document.getElementById('msg'), err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Close modals on overlay click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'reset-modal') hideResetModal();
|
||||
if (e.target.id === 'delete-modal') hideDeleteModal();
|
||||
});
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
const API = {
|
||||
async _fetch(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `Request failed (${res.status})`);
|
||||
@@ -27,13 +38,13 @@ function showMessage(el, text, type = 'success') {
|
||||
setTimeout(() => { el.className = 'message'; }, 4000);
|
||||
}
|
||||
|
||||
// Set an input[type=date] to today's date (using local time, not UTC)
|
||||
// Set an input[type=date] to today's date in the user's configured timezone
|
||||
function setToday(inputEl) {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
inputEl.value = `${y}-${m}-${d}`;
|
||||
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|
||||
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|| 'UTC';
|
||||
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
|
||||
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
|
||||
}
|
||||
|
||||
// Format YYYY-MM-DD → MM/DD/YYYY for display
|
||||
|
||||
232
nginx/html/js/auth.js
Normal file
232
nginx/html/js/auth.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// auth.js — authentication utilities used by every authenticated page
|
||||
|
||||
const Auth = {
|
||||
getToken() {
|
||||
return localStorage.getItem('token');
|
||||
},
|
||||
|
||||
setToken(token) {
|
||||
localStorage.setItem('token', token);
|
||||
},
|
||||
|
||||
removeToken() {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
|
||||
getUser() {
|
||||
const token = this.getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp < Date.now() / 1000) {
|
||||
this.removeToken();
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
requireAuth() {
|
||||
const user = this.getUser();
|
||||
if (!user) {
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.removeToken();
|
||||
sessionStorage.removeItem('admin_token');
|
||||
window.location.href = '/login';
|
||||
},
|
||||
};
|
||||
|
||||
function returnToAdmin() {
|
||||
const adminToken = sessionStorage.getItem('admin_token');
|
||||
Auth.setToken(adminToken);
|
||||
sessionStorage.removeItem('admin_token');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
|
||||
// ── Timezone helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function getUserTimezone() {
|
||||
return Auth.getUser()?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
}
|
||||
|
||||
function buildTimezoneOptions(selected) {
|
||||
let allTz;
|
||||
try {
|
||||
allTz = Intl.supportedValuesOf('timeZone');
|
||||
} catch (_) {
|
||||
// Fallback for older browsers
|
||||
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 options = 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}">${options}</optgroup>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Nav + settings modal ──────────────────────────────────────────────────────
|
||||
|
||||
function initNav() {
|
||||
const user = Auth.requireAuth();
|
||||
if (!user) return;
|
||||
|
||||
const nav = document.querySelector('.nav');
|
||||
if (!nav) return;
|
||||
|
||||
const adminToken = sessionStorage.getItem('admin_token');
|
||||
const navUser = document.createElement('div');
|
||||
navUser.className = 'nav-user';
|
||||
|
||||
if (adminToken) {
|
||||
navUser.innerHTML = `
|
||||
<span class="nav-impersonating">Viewing as <strong>${user.username}</strong></span>
|
||||
<button onclick="returnToAdmin()" class="btn btn-sm btn-amber">↩ Return to Admin</button>
|
||||
`;
|
||||
} else {
|
||||
navUser.innerHTML = `
|
||||
<span class="nav-username">${user.username}</span>
|
||||
${user.is_admin ? '<a href="/admin" class="btn btn-sm btn-ghost nav-admin-btn">Admin</a>' : ''}
|
||||
<button onclick="showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">⚙</button>
|
||||
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
|
||||
`;
|
||||
}
|
||||
|
||||
nav.appendChild(navUser);
|
||||
|
||||
if (!adminToken) {
|
||||
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" class="message"></div>
|
||||
|
||||
<h3 class="settings-section-title">Timezone</h3>
|
||||
<div class="form-group" style="margin-bottom:0.5rem">
|
||||
<label for="tz-select">Your timezone</label>
|
||||
<select id="tz-select">${tzOptions}</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.5rem">
|
||||
<button class="btn btn-sm btn-ghost" onclick="detectTimezone()">Detect automatically</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="submitTimezone()">Save 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="pw-current" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0.75rem">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="pw-new" autocomplete="new-password" minlength="6">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw-confirm" autocomplete="new-password">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="hideSettingsModal()">Close</button>
|
||||
<button class="btn btn-primary" onclick="submitPasswordChange()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function showSettingsModal() {
|
||||
document.getElementById('settings-modal').style.display = 'flex';
|
||||
document.getElementById('settings-msg').className = 'message';
|
||||
document.getElementById('pw-current').value = '';
|
||||
document.getElementById('pw-new').value = '';
|
||||
document.getElementById('pw-confirm').value = '';
|
||||
}
|
||||
|
||||
function hideSettingsModal() {
|
||||
document.getElementById('settings-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function detectTimezone() {
|
||||
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const sel = document.getElementById('tz-select');
|
||||
if (sel) sel.value = detected;
|
||||
}
|
||||
|
||||
async function submitTimezone() {
|
||||
const tz = document.getElementById('tz-select').value;
|
||||
const msgEl = document.getElementById('settings-msg');
|
||||
try {
|
||||
const data = await API.put('/api/auth/timezone', { timezone: tz });
|
||||
Auth.setToken(data.access_token);
|
||||
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
|
||||
msgEl.className = 'message success visible';
|
||||
setTimeout(() => { msgEl.className = 'message'; }, 3000);
|
||||
} catch (err) {
|
||||
msgEl.textContent = err.message;
|
||||
msgEl.className = 'message error visible';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasswordChange() {
|
||||
const current = document.getElementById('pw-current').value;
|
||||
const newPw = document.getElementById('pw-new').value;
|
||||
const confirm = document.getElementById('pw-confirm').value;
|
||||
const msgEl = document.getElementById('settings-msg');
|
||||
|
||||
if (newPw !== confirm) {
|
||||
msgEl.textContent = 'New passwords do not match';
|
||||
msgEl.className = 'message error visible';
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 6) {
|
||||
msgEl.textContent = 'Password must be at least 6 characters';
|
||||
msgEl.className = 'message error visible';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.post('/api/auth/change-password', {
|
||||
current_password: current,
|
||||
new_password: newPw,
|
||||
});
|
||||
msgEl.textContent = 'Password updated!';
|
||||
msgEl.className = 'message success visible';
|
||||
document.getElementById('pw-current').value = '';
|
||||
document.getElementById('pw-new').value = '';
|
||||
document.getElementById('pw-confirm').value = '';
|
||||
setTimeout(() => { msgEl.className = 'message'; }, 3000);
|
||||
} catch (err) {
|
||||
msgEl.textContent = err.message;
|
||||
msgEl.className = 'message error visible';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal && e.target === modal) hideSettingsModal();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initNav);
|
||||
@@ -187,7 +187,7 @@ async function exportCSV() {
|
||||
a.href = url;
|
||||
const now = new Date();
|
||||
const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
|
||||
a.download = `egg-tracker-${fileDate}.csv`;
|
||||
a.download = `yolkbook-${fileDate}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Log Eggs — Eggtracker</title>
|
||||
<title>Log Eggs — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -66,7 +66,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/log.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
161
nginx/html/login.html
Normal file
161
nginx/html/login.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-container">
|
||||
<div class="login-brand">🥚 Yolkbook</div>
|
||||
|
||||
<!-- Sign In -->
|
||||
<div class="card login-card" id="login-panel">
|
||||
<h1 class="login-title">Sign In</h1>
|
||||
<div id="login-msg" class="message"></div>
|
||||
<form id="login-form">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1.5rem">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%" id="login-btn">Sign In</button>
|
||||
</form>
|
||||
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
|
||||
No account? <a href="#" onclick="showRegister()">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Register -->
|
||||
<div class="card login-card" id="register-panel" style="display:none">
|
||||
<h1 class="login-title">Create Account</h1>
|
||||
<div id="reg-msg" class="message"></div>
|
||||
<form id="reg-form">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label for="reg-username">Username</label>
|
||||
<input type="text" id="reg-username" autocomplete="username" required minlength="2" maxlength="64">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label for="reg-password">Password</label>
|
||||
<input type="password" id="reg-password" autocomplete="new-password" required minlength="6" placeholder="min 6 characters">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1.5rem">
|
||||
<label for="reg-confirm">Confirm Password</label>
|
||||
<input type="password" id="reg-confirm" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%" id="reg-btn">Create Account</button>
|
||||
</form>
|
||||
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
|
||||
Already have an account? <a href="#" onclick="showLogin()">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Redirect if already logged in
|
||||
(function () {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp > Date.now() / 1000) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
})();
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('login-panel').style.display = 'block';
|
||||
document.getElementById('username').focus();
|
||||
}
|
||||
|
||||
function showRegister() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'block';
|
||||
document.getElementById('reg-username').focus();
|
||||
}
|
||||
|
||||
function showError(elId, text) {
|
||||
const el = document.getElementById(elId);
|
||||
el.textContent = text;
|
||||
el.className = 'message error visible';
|
||||
}
|
||||
|
||||
function showSuccess(elId, text) {
|
||||
const el = document.getElementById(elId);
|
||||
el.textContent = text;
|
||||
el.className = 'message success visible';
|
||||
}
|
||||
|
||||
// ── Login ──
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Signing in…';
|
||||
document.getElementById('login-msg').className = 'message';
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
|
||||
localStorage.setItem('token', data.access_token);
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
showError('login-msg', 'Could not reach the server. Please try again.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Register ──
|
||||
document.getElementById('reg-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('reg-btn');
|
||||
const username = document.getElementById('reg-username').value.trim();
|
||||
const password = document.getElementById('reg-password').value;
|
||||
const confirm = document.getElementById('reg-confirm').value;
|
||||
|
||||
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating account…';
|
||||
document.getElementById('reg-msg').className = 'message';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
|
||||
localStorage.setItem('token', data.access_token);
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
showError('reg-msg', 'Could not reach the server. Please try again.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Account';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Summary — Eggtracker</title>
|
||||
<title>Summary — Yolkbook</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/log">Log Eggs</a></li>
|
||||
@@ -68,7 +68,8 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/auth.js?v=3"></script>
|
||||
<script src="/js/summary.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user