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:
parent
635f56e44b
commit
9afb1dd4c5
@ -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 }),
|
||||
},
|
||||
}
|
||||
|
||||
6
frontend/src/lib/pocketbase.js
Normal file
6
frontend/src/lib/pocketbase.js
Normal file
@ -0,0 +1,6 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
|
||||
const pb = new PocketBase(window.location.origin)
|
||||
pb.autoCancellation(false)
|
||||
|
||||
export default pb
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
12
server/db.js
12
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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user