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:
@@ -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); }
|
||||
|
||||
@@ -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">🌿</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">🌿</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>
|
||||
|
||||
@@ -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