From 9afb1dd4c52e6cbdf7b4745537877c74f7ecfb15 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 11 May 2026 12:11:27 -0400 Subject: [PATCH] Switch from email to username, add password change and admin reset - Login now uses username instead of email - DB migration renames email -> username on existing databases - Users can change their own password from the Stories page - Admin can reset any user's password from the admin panel Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api.js | 18 +++++-- frontend/src/lib/pocketbase.js | 6 +++ frontend/src/pages/Admin.jsx | 86 +++++++++++++++++++++++++--------- frontend/src/pages/Login.jsx | 23 ++++----- frontend/src/pages/Stories.jsx | 35 ++++++++++++++ server/db.js | 12 +++-- server/routes/admin.js | 25 ++++++---- server/routes/auth.js | 28 ++++++++--- 8 files changed, 178 insertions(+), 55 deletions(-) create mode 100644 frontend/src/lib/pocketbase.js diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index d614ae4..490c105 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -14,6 +14,12 @@ export function getUser() { } catch { return null } } +// Support tokens that still carry `email` from before the username migration +export function getUsername() { + const u = getUser() + return u?.username ?? u?.email ?? '' +} + async function req(method, url, body, extraHeaders = {}) { const token = getToken() const isForm = body instanceof FormData @@ -46,12 +52,13 @@ async function download(url) { } export const api = { - login: async (email, password) => { - const data = await req('POST', '/api/auth/login', { email, password }) + login: async (username, password) => { + const data = await req('POST', '/api/auth/login', { username, password }) setToken(data.token) return data.user }, logout: clearToken, + changePassword: (current, newPassword) => req('PUT', '/api/auth/password', { current, newPassword }), getStories: () => req('GET', '/api/stories'), getStory: (id) => req('GET', `/api/stories/${id}`), @@ -71,8 +78,9 @@ export const api = { exportOdt: (id) => download(`/api/stories/${id}/export/odt`), admin: { - getUsers: (pw) => req('GET', '/api/admin/users', null, { 'x-admin-password': pw }), - createUser: (pw, data) => req('POST', '/api/admin/users', data, { 'x-admin-password': pw }), - deleteUser: (pw, id) => req('DELETE', `/api/admin/users/${id}`, null, { 'x-admin-password': pw }), + getUsers: (pw) => req('GET', '/api/admin/users', null, { 'x-admin-password': pw }), + createUser: (pw, data) => req('POST', '/api/admin/users', data, { 'x-admin-password': pw }), + resetPassword: (pw, id, pass) => req('PUT', `/api/admin/users/${id}/password`, { password: pass }, { 'x-admin-password': pw }), + deleteUser: (pw, id) => req('DELETE', `/api/admin/users/${id}`, null, { 'x-admin-password': pw }), }, } diff --git a/frontend/src/lib/pocketbase.js b/frontend/src/lib/pocketbase.js new file mode 100644 index 0000000..66f6f1f --- /dev/null +++ b/frontend/src/lib/pocketbase.js @@ -0,0 +1,6 @@ +import PocketBase from 'pocketbase' + +const pb = new PocketBase(window.location.origin) +pb.autoCancellation(false) + +export default pb diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 6e76ec3..7bec6df 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -7,17 +7,20 @@ export default function Admin() { const [users, setUsers] = useState([]) const [error, setError] = useState('') - const [form, setForm] = useState({ name: '', email: '', password: '' }) + const [form, setForm] = useState({ name: '', username: '', password: '' }) const [formError, setFormError] = useState('') const [formSuccess, setFormSuccess] = useState('') const [creating, setCreating] = useState(false) + const [resetId, setResetId] = useState(null) + const [resetPw, setResetPw] = useState('') + const [resetMsg, setResetMsg] = useState('') + async function login(e) { e.preventDefault() setError('') try { const list = await api.admin.getUsers(pw) - if (list.error) { setError('Wrong password'); return } setUsers(list) setAuthed(true) } catch { @@ -32,9 +35,8 @@ export default function Admin() { setCreating(true) try { const user = await api.admin.createUser(pw, form) - if (user.error) { setFormError(user.error); return } setUsers(prev => [user, ...prev]) - setForm({ name: '', email: '', password: '' }) + setForm({ name: '', username: '', password: '' }) setFormSuccess(`Account created for ${user.name}!`) } catch (err) { setFormError(err.message) @@ -43,6 +45,19 @@ export default function Admin() { } } + async function resetPassword(e, id) { + e.preventDefault() + setResetMsg('') + try { + await api.admin.resetPassword(pw, id, resetPw) + setResetMsg('Password updated') + setResetPw('') + setTimeout(() => { setResetId(null); setResetMsg('') }, 1500) + } catch (err) { + setResetMsg(err.message) + } + } + async function deleteUser(id, name) { if (!confirm(`Delete ${name}'s account and all their stories?`)) return await api.admin.deleteUser(pw, id) @@ -87,19 +102,20 @@ export default function Admin() {
setForm(f => ({ ...f, name: e.target.value }))} required className="input" /> setForm(f => ({ ...f, email: e.target.value }))} + type="text" + placeholder="Username" + value={form.username} + onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required className="input" + autoCapitalize="none" /> {formError}

} {formSuccess &&

{formSuccess}

}
@@ -124,18 +140,46 @@ export default function Admin() { ) : ( diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 39b8024..1e8ce06 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,20 +2,20 @@ import { useState } from 'react' import { api } from '../lib/api' export default function Login({ onLogin }) { - const [email, setEmail] = useState('') + const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) async function handleSubmit(e) { e.preventDefault() setLoading(true) setError('') try { - const user = await api.login(email, password) + const user = await api.login(username, password) onLogin(user) } catch (err) { - setError(err.message || 'Wrong email or password') + setError(err.message || 'Wrong username or password') } finally { setLoading(false) } @@ -29,13 +29,14 @@ export default function Login({ onLogin }) {

Your stories, your world

setEmail(e.target.value)} + type="text" + placeholder="Username" + value={username} + onChange={e => setUsername(e.target.value)} required className="input" - autoComplete="email" + autoComplete="username" + autoCapitalize="none" /> {error &&

{error}

}
diff --git a/frontend/src/pages/Stories.jsx b/frontend/src/pages/Stories.jsx index cad4957..25fbed0 100644 --- a/frontend/src/pages/Stories.jsx +++ b/frontend/src/pages/Stories.jsx @@ -14,6 +14,10 @@ export default function Stories({ onLogout }) { const [goal, setGoal] = useState({ target: 0, count: 0 }) const [settingGoal, setSettingGoal] = useState(false) const [goalInput, setGoalInput] = useState('') + const [changingPw, setChangingPw] = useState(false) + const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' }) + const [pwError, setPwError] = useState('') + const [pwSuccess, setPwSuccess] = useState(false) const navigate = useNavigate() const confirm = useConfirm() const toast = useToast() @@ -49,6 +53,21 @@ export default function Stories({ onLogout }) { function logout() { api.logout(); onLogout() } + async function changePassword(e) { + e.preventDefault() + setPwError('') + if (pwForm.next !== pwForm.confirm) { setPwError('Passwords do not match'); return } + if (pwForm.next.length < 6) { setPwError('New password must be at least 6 characters'); return } + try { + await api.changePassword(pwForm.current, pwForm.next) + setPwSuccess(true) + setPwForm({ current: '', next: '', confirm: '' }) + setTimeout(() => { setChangingPw(false); setPwSuccess(false) }, 1500) + } catch (err) { + setPwError(err.message) + } + } + function saveGoal(e) { e.preventDefault() const t = parseInt(goalInput) @@ -67,10 +86,26 @@ export default function Stories({ onLogout }) {

My Stories

+
+ {changingPw && ( +
+ + setPwForm(f => ({ ...f, current: e.target.value }))} required autoFocus /> + setPwForm(f => ({ ...f, next: e.target.value }))} required /> + setPwForm(f => ({ ...f, confirm: e.target.value }))} required /> + {pwError &&

{pwError}

} + {pwSuccess &&

Password updated!

} +
+ + +
+
+ )} + {/* Stats bar */}
{streak > 0 && ( diff --git a/server/db.js b/server/db.js index 97d9082..edc07bd 100644 --- a/server/db.js +++ b/server/db.js @@ -13,9 +13,9 @@ db.pragma('foreign_keys = ON') db.exec(` CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL COLLATE NOCASE, + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT UNIQUE NOT NULL COLLATE NOCASE, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -38,4 +38,10 @@ db.exec(` ); `) +// Migrate: rename email -> username for existing databases +const userCols = db.pragma('table_info(users)').map(c => c.name) +if (userCols.includes('email') && !userCols.includes('username')) { + db.exec('ALTER TABLE users RENAME COLUMN email TO username') +} + export default db diff --git a/server/routes/admin.js b/server/routes/admin.js index 7a62904..635dc47 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -8,27 +8,36 @@ router.use(adminAuth) router.get('/users', (req, res) => { const users = db.prepare( - 'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC' + 'SELECT id, name, username, created_at FROM users ORDER BY created_at DESC' ).all() res.json(users) }) router.post('/users', (req, res) => { - const { name, email, password } = req.body || {} - if (!name || !email || !password) - return res.status(400).json({ error: 'Name, email, and password are all required' }) + const { name, username, password } = req.body || {} + if (!name || !username || !password) + return res.status(400).json({ error: 'Name, username, and password are all required' }) try { const hash = bcrypt.hashSync(password, 10) const { lastInsertRowid } = db.prepare( - 'INSERT INTO users (name, email, password) VALUES (?, ?, ?)' - ).run(name, email.toLowerCase(), hash) - res.json({ id: lastInsertRowid, name, email }) + 'INSERT INTO users (name, username, password) VALUES (?, ?, ?)' + ).run(name, username.toLowerCase(), hash) + res.json({ id: lastInsertRowid, name, username }) } catch { - res.status(400).json({ error: 'That email is already in use' }) + res.status(400).json({ error: 'That username is already taken' }) } }) +router.put('/users/:id/password', (req, res) => { + const { password } = req.body || {} + if (!password) return res.status(400).json({ error: 'Password required' }) + if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }) + + db.prepare('UPDATE users SET password = ? WHERE id = ?').run(bcrypt.hashSync(password, 10), req.params.id) + res.json({ ok: true }) +}) + router.delete('/users/:id', (req, res) => { db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id) res.json({ ok: true }) diff --git a/server/routes/auth.js b/server/routes/auth.js index 337d624..82efb9c 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,25 +2,39 @@ import { Router } from 'express' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import db from '../db.js' -import { JWT_SECRET } from '../middleware/auth.js' +import { auth, JWT_SECRET } from '../middleware/auth.js' const router = Router() router.post('/login', (req, res) => { - const { email, password } = req.body || {} - if (!email || !password) return res.status(400).json({ error: 'Email and password required' }) + const { username, password } = req.body || {} + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }) - const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) if (!user || !bcrypt.compareSync(password, user.password)) { - return res.status(401).json({ error: 'Wrong email or password' }) + return res.status(401).json({ error: 'Wrong username or password' }) } const token = jwt.sign( - { id: user.id, email: user.email, name: user.name }, + { id: user.id, username: user.username, name: user.name }, JWT_SECRET, { expiresIn: '30d' } ) - res.json({ token, user: { id: user.id, email: user.email, name: user.name } }) + res.json({ token, user: { id: user.id, username: user.username, name: user.name } }) +}) + +router.put('/password', auth, (req, res) => { + const { current, newPassword } = req.body || {} + if (!current || !newPassword) return res.status(400).json({ error: 'Current and new password required' }) + if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' }) + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id) + if (!bcrypt.compareSync(current, user.password)) { + return res.status(401).json({ error: 'Current password is incorrect' }) + } + + db.prepare('UPDATE users SET password = ? WHERE id = ?').run(bcrypt.hashSync(newPassword, 10), req.user.id) + res.json({ ok: true }) }) export default router