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

@@ -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);