Add multi-user authentication with JWT

- Users table with email/bcrypt-hashed password; register and login via /auth/ endpoints
- JWT tokens (30-day expiry) stored in localStorage; all API routes require Bearer auth
- All data (varieties, batches, settings, notification logs) scoped to the authenticated user
- Login/register screen overlays the app; sidebar shows user email and logout button
- Scheduler sends daily ntfy summaries for every configured user
- DB schema rewritten for multi-user; SECRET_KEY added to env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:08:28 -07:00
parent 1bed02ebb5
commit 4db9988406
17 changed files with 470 additions and 115 deletions

View File

@@ -634,3 +634,50 @@ a:hover { text-decoration: underline; }
.auth-toggle { display: flex; gap: 1.25rem; flex-wrap: wrap; }
.auth-toggle-option { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.9rem; }
.auth-toggle-option input[type="radio"] { accent-color: var(--green-mid); }
/* ===== Auth Screen ===== */
.auth-overlay {
position: fixed; inset: 0; z-index: 1000;
background: linear-gradient(135deg, #d8f3dc 0%, #b7e4c7 50%, #95d5b2 100%);
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.auth-card {
background: var(--bg-card); border-radius: 1rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
padding: 2.5rem 2rem; width: 100%; max-width: 400px;
}
.auth-brand {
display: flex; align-items: center; gap: 0.5rem;
justify-content: center; margin-bottom: 2rem;
font-size: 1.6rem; font-weight: 700; color: var(--green-dark);
}
.auth-brand .brand-icon { font-size: 2rem; }
.auth-tabs {
display: flex; margin-bottom: 1.5rem;
border-bottom: 2px solid var(--border);
}
.auth-tab {
flex: 1; padding: 0.6rem; background: none; border: none;
font-size: 0.9rem; font-weight: 500; color: var(--text-light);
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.auth-tab.active { color: var(--green-dark); border-bottom-color: var(--green-dark); }
.auth-msg { padding: 0.6rem 0.75rem; border-radius: 0.4rem; font-size: 0.85rem; margin-bottom: 1rem; }
.auth-msg.error { background: #fee2e2; color: #b91c1c; }
.btn-full { width: 100%; justify-content: center; margin-top: 0.5rem; }
/* Sidebar user + logout */
.sidebar-user {
display: block; font-size: 0.75rem; color: var(--text-light);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%; margin-bottom: 0.25rem;
}
.btn-logout {
width: 100%; padding: 0.4rem; margin-top: 0.5rem;
background: none; border: 1px solid var(--border); border-radius: 0.4rem;
color: var(--text-light); font-size: 0.8rem; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-logout:hover { background: var(--border); color: var(--text); }

View File

@@ -8,6 +8,53 @@
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<!-- AUTH SCREEN -->
<div id="auth-screen" class="auth-overlay">
<div class="auth-card">
<div class="auth-brand">
<span class="brand-icon">&#127807;</span>
<span class="brand-name">Sproutly</span>
</div>
<div class="auth-tabs">
<button id="tab-login" class="auth-tab active" onclick="Auth.showTab('login')">Log In</button>
<button id="tab-register" class="auth-tab" onclick="Auth.showTab('register')">Create Account</button>
</div>
<div id="auth-login-panel">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="auth-email" class="form-input" placeholder="you@example.com"
onkeydown="if(event.key==='Enter') Auth.submit()" />
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="auth-password" class="form-input" placeholder="Password"
onkeydown="if(event.key==='Enter') Auth.submit()" />
</div>
<div id="auth-error" class="auth-msg error hidden"></div>
<button class="btn btn-primary btn-full" onclick="Auth.submit()">Log In</button>
</div>
<div id="auth-register-panel" class="hidden">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="reg-email" class="form-input" placeholder="you@example.com"
onkeydown="if(event.key==='Enter') Auth.submitRegister()" />
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="reg-password" class="form-input" placeholder="At least 8 characters"
onkeydown="if(event.key==='Enter') Auth.submitRegister()" />
</div>
<div id="reg-error" class="auth-msg error hidden"></div>
<button class="btn btn-primary btn-full" onclick="Auth.submitRegister()">Create Account</button>
</div>
</div>
</div>
<!-- APP SHELL -->
<div id="app-shell" class="hidden">
<aside class="sidebar">
<div class="sidebar-brand">
<span class="brand-icon">&#127807;</span>
@@ -28,7 +75,9 @@
</a>
</nav>
<div class="sidebar-footer">
<span id="sidebar-user" class="sidebar-user"></span>
<span id="sidebar-date"></span>
<button class="btn-logout" onclick="Auth.logout()">Log out</button>
</div>
</aside>
@@ -243,6 +292,8 @@
<div id="toast" class="toast hidden"></div>
</div><!-- /app-shell -->
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -1,13 +1,118 @@
/* Sproutly Frontend — Vanilla JS SPA */
const API = '/api';
// ===== Auth =====
const Auth = (() => {
function showTab(tab) {
document.getElementById('auth-login-panel').classList.toggle('hidden', tab !== 'login');
document.getElementById('auth-register-panel').classList.toggle('hidden', tab !== 'register');
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
document.getElementById('auth-error').classList.add('hidden');
document.getElementById('reg-error').classList.add('hidden');
}
async function submit() {
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const errEl = document.getElementById('auth-error');
errEl.classList.add('hidden');
try {
const res = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
errEl.textContent = err.detail || 'Login failed';
errEl.classList.remove('hidden');
return;
}
const data = await res.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
async function submitRegister() {
const email = document.getElementById('reg-email').value.trim();
const password = document.getElementById('reg-password').value;
const errEl = document.getElementById('reg-error');
errEl.classList.add('hidden');
if (password.length < 8) {
errEl.textContent = 'Password must be at least 8 characters';
errEl.classList.remove('hidden');
return;
}
try {
const res = await fetch(API + '/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Registration failed' }));
errEl.textContent = err.detail || 'Registration failed';
errEl.classList.remove('hidden');
return;
}
// Auto-login after register
const loginRes = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await loginRes.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
function logout() {
localStorage.removeItem('sproutly_token');
localStorage.removeItem('sproutly_user');
document.getElementById('app-shell').classList.add('hidden');
document.getElementById('auth-screen').classList.remove('hidden');
showTab('login');
}
return { showTab, submit, submitRegister, logout };
})();
function showApp() {
document.getElementById('auth-screen').classList.add('hidden');
document.getElementById('app-shell').classList.remove('hidden');
const email = localStorage.getItem('sproutly_user') || '';
document.getElementById('sidebar-user').textContent = email;
}
// ===== API Helpers =====
async function apiFetch(path, opts = {}) {
const token = localStorage.getItem('sproutly_token');
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
Auth.logout();
throw new Error('Session expired — please log in again');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || res.statusText);
@@ -786,20 +891,31 @@ async function deleteBatch(id) {
}
// ===== Init =====
function init() {
// Sidebar date
function initApp() {
document.getElementById('sidebar-date').textContent =
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Navigation via hash
function handleNav() {
const page = (location.hash.replace('#','') || 'dashboard');
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
}
window.removeEventListener('hashchange', handleNav);
window.addEventListener('hashchange', handleNav);
handleNav();
}
async function init() {
const token = localStorage.getItem('sproutly_token');
if (!token) return; // auth screen is visible by default
try {
await apiFetch('/auth/me');
showApp();
initApp();
} catch (e) {
// token invalid — auth screen stays visible
}
}
// ===== Public API =====
window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
@@ -809,4 +925,11 @@ window.App = {
closeModal: (e) => closeModal(e),
};
window.Auth = {
showTab: (t) => Auth.showTab(t),
submit: () => Auth.submit(),
submitRegister: () => Auth.submitRegister(),
logout: () => Auth.logout(),
};
document.addEventListener('DOMContentLoaded', init);