From 5af88eb5ce444ed090e8d590d43dd66ad12396a4 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Mon, 22 Jun 2026 22:33:11 +1000 Subject: [PATCH] Add email/password auth + SMTP password resets - users + password_resets tables; bcrypt hashing; JWT session in an httpOnly cookie (auth_secret auto-generated in settings) - /api gated behind auth (except /api/config and /api/auth/*) - First-run creates the account (no open registration after that); login, logout, forgot/reset-password flows - SMTP settings (host/port/secure/user/pass/from) in Settings menu; nodemailer sends reset links - Auth screen (sign in / create account / forgot / reset) gates the SPA; Sign out button; COOKIE_SECURE=true in compose for HTTPS Co-Authored-By: Claude Opus 4.8 --- db/schema.sql | 18 ++++++ docker-compose.yml | 1 + lib/auth.js | 78 +++++++++++++++++++++++ lib/mailer.js | 30 +++++++++ lib/store.js | 46 ++++++++++++++ package-lock.json | 152 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 ++ public/app.js | 101 +++++++++++++++++++++++++++++- public/index.html | 59 +++++++++++++++++- public/styles.css | 20 ++++++ server.js | 114 ++++++++++++++++++++++++++++++++++ 11 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 lib/auth.js create mode 100644 lib/mailer.js diff --git a/db/schema.sql b/db/schema.sql index ab230cd..d979465 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -2,6 +2,24 @@ -- A "model" is one logical download (a Thingiverse thing, a Printables model, a -- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...). +-- App users (email/password auth). +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One-time password-reset tokens (token stored hashed). +CREATE TABLE IF NOT EXISTS password_resets ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT false +); +CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash); + -- Simple key/value app settings (API tokens, site auth). Editable from the UI. CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, diff --git a/docker-compose.yml b/docker-compose.yml index 4fe9669..1d27f68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: environment: DATABASE_URL: postgres://printhive:${POSTGRES_PASSWORD:-printhive}@db:5432/printhive PORT: 3000 + COOKIE_SECURE: "true" # served over HTTPS via Traefik # Optional — these can also be set in the app's Settings menu (stored in the DB). THINGIVERSE_TOKEN: ${THINGIVERSE_TOKEN:-} FLARESOLVERR_URL: ${FLARESOLVERR_URL:-} diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..574a6f0 --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,78 @@ +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const store = require('./store'); + +const COOKIE = 'ph_session'; +const TOKEN_TTL = '30d'; + +// JWT signing secret — generated once and persisted in settings. +let cachedSecret = null; +async function getSecret() { + if (cachedSecret) return cachedSecret; + let secret = await store.getSetting('auth_secret'); + if (!secret) { + secret = crypto.randomBytes(48).toString('hex'); + await store.setSetting('auth_secret', secret); + } + cachedSecret = secret; + return secret; +} + +function hashPassword(password) { + return bcrypt.hash(password, 12); +} +function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); +} + +async function issueToken(user) { + return jwt.sign({ sub: user.id, email: user.email }, await getSecret(), { expiresIn: TOKEN_TTL }); +} + +function setAuthCookie(res, token) { + res.cookie(COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.COOKIE_SECURE === 'true', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); +} +function clearAuthCookie(res) { + res.clearCookie(COOKIE); +} + +// Resolve the current user from the session cookie, or null. +async function currentUser(req) { + const token = req.cookies && req.cookies[COOKIE]; + if (!token) return null; + try { + const payload = jwt.verify(token, await getSecret()); + return store.getUserById(payload.sub); + } catch { + return null; + } +} + +// Express middleware: 401 unless authenticated. +async function requireAuth(req, res, next) { + const user = await currentUser(req); + if (!user) return res.status(401).json({ error: 'Authentication required' }); + req.user = user; + next(); +} + +// Password-reset tokens: return { token, hash } — store the hash, email the token. +function makeResetToken() { + const token = crypto.randomBytes(32).toString('hex'); + const hash = crypto.createHash('sha256').update(token).digest('hex'); + return { token, hash }; +} +function hashResetToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +module.exports = { + COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie, + currentUser, requireAuth, makeResetToken, hashResetToken, +}; diff --git a/lib/mailer.js b/lib/mailer.js new file mode 100644 index 0000000..2734a37 --- /dev/null +++ b/lib/mailer.js @@ -0,0 +1,30 @@ +const nodemailer = require('nodemailer'); + +// Build a transport from the resolved SMTP settings, or null if not configured. +function buildTransport(smtp) { + if (!smtp.host) return null; + const port = Number(smtp.port) || 587; + return nodemailer.createTransport({ + host: smtp.host, + port, + secure: smtp.secure === true || smtp.secure === 'true' || port === 465, + auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined, + }); +} + +// smtp: { host, port, secure, user, pass, from } +async function sendPasswordReset(smtp, to, link) { + const transport = buildTransport(smtp); + if (!transport) throw new Error('SMTP is not configured.'); + await transport.sendMail({ + from: smtp.from || smtp.user, + to, + subject: 'Printhive password reset', + text: `Reset your Printhive password using this link (valid for 1 hour):\n\n${link}\n\nIf you didn't request this, you can ignore this email.`, + html: `

Reset your Printhive password using this link (valid for 1 hour):

+

${link}

+

If you didn't request this, you can ignore this email.

`, + }); +} + +module.exports = { buildTransport, sendPasswordReset }; diff --git a/lib/store.js b/lib/store.js index 8ec2be6..6627347 100644 --- a/lib/store.js +++ b/lib/store.js @@ -208,6 +208,50 @@ async function deleteProject(id) { return rowCount > 0; } +// ---------------------------------------------------------------- users / auth +async function countUsers() { + const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users'); + return rows[0].n; +} + +async function getUserByEmail(email) { + const { rows } = await db.query('SELECT * FROM users WHERE lower(email) = lower($1)', [email]); + return rows[0] || null; +} + +async function getUserById(id) { + const { rows } = await db.query('SELECT id, email, created_at FROM users WHERE id = $1', [id]); + return rows[0] || null; +} + +async function createUser(email, passwordHash) { + const { rows } = await db.query( + 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, created_at', + [email.trim(), passwordHash]); + return rows[0]; +} + +async function updateUserPassword(id, passwordHash) { + await db.query('UPDATE users SET password_hash = $2 WHERE id = $1', [id, passwordHash]); +} + +async function createPasswordReset(userId, tokenHash, expiresAt) { + await db.query( + 'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1,$2,$3)', + [userId, tokenHash, expiresAt]); +} + +async function getValidReset(tokenHash) { + const { rows } = await db.query( + `SELECT * FROM password_resets + WHERE token_hash = $1 AND used = false AND expires_at > now()`, [tokenHash]); + return rows[0] || null; +} + +async function markResetUsed(id) { + await db.query('UPDATE password_resets SET used = true WHERE id = $1', [id]); +} + // ---------------------------------------------------------------- settings async function getSetting(key) { const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]); @@ -241,4 +285,6 @@ module.exports = { getAllTags, getAllProjects, getProject, createProject, updateProject, deleteProject, getSetting, setSetting, + countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, + createPasswordReset, getValidReset, markResetUsed, }; diff --git a/package-lock.json b/package-lock.json index 390a0ba..f1e5660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,23 @@ { - "name": "stl-library-manager", + "name": "printhive", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "stl-library-manager", + "name": "printhive", "version": "1.0.0", "license": "MIT", "dependencies": { "adm-zip": "^0.5.17", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "dotenv": "^17.4.2", "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "node-stl": "^0.7.0", + "nodemailer": "^9.0.1", "pg": "^8.22.0" }, "devDependencies": { @@ -78,6 +82,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -141,6 +154,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -266,6 +285,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -332,6 +370,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -713,6 +760,97 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -859,6 +997,15 @@ "integrity": "sha512-ZdqkD7vE7VskSOVgj6p9z9G5pYEXIAq53Vw276kCPdZyimintgxqwpbdwPAxspgJIvsrno3q8jB8g7UDwNREGg==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1241,7 +1388,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index feb98bd..688f463 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,14 @@ "license": "MIT", "dependencies": { "adm-zip": "^0.5.17", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "dotenv": "^17.4.2", "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "node-stl": "^0.7.0", + "nodemailer": "^9.0.1", "pg": "^8.22.0" }, "devDependencies": { diff --git a/public/app.js b/public/app.js index 78a7979..f222434 100644 --- a/public/app.js +++ b/public/app.js @@ -20,10 +20,106 @@ const editModal = document.getElementById('editModal'); const fileDetailModal = document.getElementById('fileDetailModal'); document.addEventListener('DOMContentLoaded', () => { - loadData(); - setupEventListeners(); + setupAuthListeners(); + bootstrap(); }); +// Decide what to show: the app (if signed in), the reset form (if ?reset=...), +// or the sign-in / create-account screen. +async function bootstrap() { + const resetToken = new URLSearchParams(location.search).get('reset'); + if (resetToken) { showAuth('reset'); return; } + let status; + try { + status = await (await fetch('/api/auth/status')).json(); + } catch { + status = { authenticated: false, needsSetup: false }; + } + if (status.authenticated) startApp(); + else showAuth(status.needsSetup ? 'register' : 'login'); +} + +let appStarted = false; +function startApp() { + document.getElementById('authScreen').style.display = 'none'; + document.querySelector('.container').style.display = ''; + if (!appStarted) { setupEventListeners(); appStarted = true; } + loadData(); +} + +function showAuth(mode) { + document.querySelector('.container').style.display = 'none'; + document.getElementById('authScreen').style.display = 'flex'; + ['login', 'register', 'forgot', 'reset'].forEach((m) => { + document.getElementById('auth-' + m).style.display = m === mode ? '' : 'none'; + }); + document.getElementById('authError').textContent = ''; +} + +function setupAuthListeners() { + document.getElementById('auth-login').addEventListener('submit', handleLogin); + document.getElementById('auth-register').addEventListener('submit', handleRegister); + document.getElementById('auth-forgot').addEventListener('submit', handleForgot); + document.getElementById('auth-reset').addEventListener('submit', handleReset); + document.querySelectorAll('.auth-nav').forEach((b) => { + b.addEventListener('click', () => showAuth(b.dataset.auth)); + }); +} + +async function authPost(url, body) { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Something went wrong.'); + return data; +} +function authError(msg) { document.getElementById('authError').textContent = msg; } + +async function handleLogin(e) { + e.preventDefault(); + try { + await authPost('/api/auth/login', { + email: document.getElementById('loginEmail').value, + password: document.getElementById('loginPassword').value, + }); + location.reload(); + } catch (err) { authError(err.message); } +} + +async function handleRegister(e) { + e.preventDefault(); + try { + await authPost('/api/auth/register', { + email: document.getElementById('regEmail').value, + password: document.getElementById('regPassword').value, + }); + location.reload(); + } catch (err) { authError(err.message); } +} + +async function handleForgot(e) { + e.preventDefault(); + try { + await authPost('/api/auth/forgot', { email: document.getElementById('forgotEmail').value }); + authError('If that email has an account and email is configured, a reset link is on its way.'); + } catch (err) { authError(err.message); } +} + +async function handleReset(e) { + e.preventDefault(); + const token = new URLSearchParams(location.search).get('reset'); + try { + await authPost('/api/auth/reset', { token, password: document.getElementById('resetPassword').value }); + window.history.replaceState({}, '', location.pathname); // drop ?reset= + showAuth('login'); + authError('Password updated — you can sign in now.'); + } catch (err) { authError(err.message); } +} + +async function handleLogout() { + try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ } + location.reload(); +} + async function loadData() { try { const [modelsRes, projectsRes, configRes] = await Promise.all([ @@ -166,6 +262,7 @@ function setupEventListeners() { document.getElementById('newProjectBtn').addEventListener('click', openProjectModal); document.getElementById('importBtn').addEventListener('click', openImportModal); document.getElementById('settingsBtn').addEventListener('click', openSettingsModal); + document.getElementById('logoutBtn').addEventListener('click', handleLogout); document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings); document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal)); document.querySelectorAll('.clear-setting').forEach((btn) => { diff --git a/public/index.html b/public/index.html index 26b347e..7801b96 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,52 @@ -
+ + + +