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}`),
@ -73,6 +80,7 @@ export const api = {
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 }),
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">
<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.email}</span>
<span className="user-email">@{u.username}</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
className="btn btn-ghost"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem', color: 'var(--danger)', borderColor: 'var(--danger)' }}
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>
{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,7 +2,7 @@ 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)
@ -12,10 +12,10 @@ export default function Login({ onLogin }) {
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

@ -15,7 +15,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
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