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() {
}
{formSuccess && {formSuccess}
}
@@ -124,18 +140,46 @@ export default function Admin() {
) : (
{users.map(u => (
- -
-
-
{u.name}
-
{u.email}
+
-
+
+
+ {u.name}
+ @{u.username}
+
+
+
+
+
-
+ {resetId === u.id && (
+
+ )}
))}
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
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 && (
+
+ )}
+
{/* 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