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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user