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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-11 12:11:27 -04:00
parent 635f56e44b
commit 9afb1dd4c5
8 changed files with 178 additions and 55 deletions

View File

@ -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 }),
},
}

View File

@ -0,0 +1,6 @@
import PocketBase from 'pocketbase'
const pb = new PocketBase(window.location.origin)
pb.autoCancellation(false)
export default pb

View File

@ -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() {
<form onSubmit={createUser} className="login-form">
<input
type="text"
placeholder="Name"
placeholder="Display name"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
required
className="input"
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={e => 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"
/>
<input
type="password"
@ -112,7 +128,7 @@ export default function Admin() {
{formError && <p className="error-msg">{formError}</p>}
{formSuccess && <p className="success-msg">{formSuccess}</p>}
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Account'}
{creating ? 'Creating' : 'Create Account'}
</button>
</form>
</section>
@ -124,18 +140,46 @@ export default function Admin() {
) : (
<ul className="user-list">
{users.map(u => (
<li key={u.id} className="user-row">
<div>
<strong>{u.name}</strong>
<span className="user-email">{u.email}</span>
<li key={u.id} className="user-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<strong>{u.name}</strong>
<span className="user-email">@{u.username}</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
className="btn btn-ghost"
style={{ fontSize: '0.72rem', padding: '0.25rem 0.5rem' }}
onClick={() => { setResetId(resetId === u.id ? null : u.id); setResetPw(''); setResetMsg('') }}
>
{resetId === u.id ? 'Cancel' : 'Reset PW'}
</button>
<button
className="btn btn-ghost"
style={{ fontSize: '0.72rem', padding: '0.25rem 0.5rem', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => deleteUser(u.id, u.name)}
>
Delete
</button>
</div>
</div>
<button
className="btn btn-ghost"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => deleteUser(u.id, u.name)}
>
Delete
</button>
{resetId === u.id && (
<form onSubmit={e => resetPassword(e, u.id)} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="password"
placeholder="New password"
value={resetPw}
onChange={e => setResetPw(e.target.value)}
required
minLength={6}
className="input"
style={{ flex: 1, minWidth: 0 }}
autoFocus
/>
<button type="submit" className="btn btn-primary" style={{ flexShrink: 0 }}>Set</button>
{resetMsg && <p className={resetMsg === 'Password updated' ? 'success-msg' : 'error-msg'} style={{ width: '100%', margin: 0 }}>{resetMsg}</p>}
</form>
)}
</li>
))}
</ul>

View File

@ -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 }) {
<p className="login-sub">Your stories, your world</p>
<form onSubmit={handleSubmit} className="login-form">
<input
type="email"
placeholder="Email"
value={email}
onChange={e => 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"
/>
<input
type="password"
@ -48,7 +49,7 @@ export default function Login({ onLogin }) {
/>
{error && <p className="error-msg">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Opening...' : 'Enter'}
{loading ? 'Opening' : 'Enter'}
</button>
</form>
</div>

View File

@ -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 }) {
<h1 className="page-title">My Stories</h1>
<div className="header-actions">
<button onClick={newStory} className="btn btn-primary">+ New Story</button>
<button onClick={() => { setChangingPw(o => !o); setPwError(''); setPwSuccess(false) }} className="btn btn-ghost">Password</button>
<button onClick={logout} className="btn btn-ghost">Sign Out</button>
</div>
</header>
{changingPw && (
<form className="goal-form" onSubmit={changePassword} style={{ alignItems: 'flex-start', flexDirection: 'column', maxWidth: 320 }}>
<label className="goal-label">Change password</label>
<input type="password" className="input" placeholder="Current password" value={pwForm.current} onChange={e => setPwForm(f => ({ ...f, current: e.target.value }))} required autoFocus />
<input type="password" className="input" placeholder="New password" value={pwForm.next} onChange={e => setPwForm(f => ({ ...f, next: e.target.value }))} required />
<input type="password" className="input" placeholder="Confirm new" value={pwForm.confirm} onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} required />
{pwError && <p className="error-msg">{pwError}</p>}
{pwSuccess && <p className="success-msg">Password updated!</p>}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="submit" className="btn btn-primary">Update</button>
<button type="button" className="btn btn-ghost" onClick={() => setChangingPw(false)}>Cancel</button>
</div>
</form>
)}
{/* Stats bar */}
<div className="stats-bar">
{streak > 0 && (

View File

@ -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

View File

@ -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 })

View File

@ -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