feat: implement user authentication and admin management

- Add user management API endpoints for listing and creating users.
- Implement login and logout functionality with rate limiting.
- Create UI components for login form, logout button, and animated elements.
- Add session management with JWT and secure cookie handling.
- Seed initial admin user if no users exist.
- Introduce utility functions for password hashing and user authentication.
- Set up proxy middleware for route protection and security headers.
- Create a JSON file for user data storage.
- Add links configuration for external resources.
This commit is contained in:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+301
View File
@@ -0,0 +1,301 @@
'use client'
import { useState, useCallback } from 'react'
import type { PublicUser, Role } from '@/lib/users'
interface Props {
initialUsers: PublicUser[]
currentUserId: string
}
export default function UsersClient({ initialUsers, currentUserId }: Props) {
const [users, setUsers] = useState<PublicUser[]>(initialUsers)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
// Create user form state
const [creating, setCreating] = useState(false)
const [createUsername, setCreateUsername] = useState('')
const [createPassword, setCreatePassword] = useState('')
const [createRole, setCreateRole] = useState<Role>('user')
const [createLoading, setCreateLoading] = useState(false)
// Password reset state — maps userId → new password string
const [pwReset, setPwReset] = useState<Record<string, string>>({})
const [pwLoading, setPwLoading] = useState<string | null>(null)
function flash(msg: string) {
setSuccessMsg(msg)
setTimeout(() => setSuccessMsg(''), 3000)
}
const refreshUsers = useCallback(async () => {
const res = await fetch('/api/admin/users')
if (res.ok) {
setUsers((await res.json()) as PublicUser[])
}
}, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setCreateLoading(true)
setError('')
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: createUsername, password: createPassword, role: createRole }),
})
const data = (await res.json()) as { error?: string }
setCreateLoading(false)
if (!res.ok) {
setError(data.error ?? 'Failed to create user')
return
}
setCreateUsername('')
setCreatePassword('')
setCreateRole('user')
setCreating(false)
await refreshUsers()
flash('User created successfully')
}
async function handleDelete(id: string, username: string) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return
setError('')
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
const data = (await res.json()) as { error?: string }
if (!res.ok) {
setError(data.error ?? 'Failed to delete user')
return
}
await refreshUsers()
flash(`User "${username}" deleted`)
}
async function handlePasswordReset(id: string) {
const pw = pwReset[id] ?? ''
if (pw.length < 8) {
setError('New password must be at least 8 characters')
return
}
setError('')
setPwLoading(id)
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
})
const data = (await res.json()) as { error?: string }
setPwLoading(null)
if (!res.ok) {
setError(data.error ?? 'Failed to update password')
return
}
setPwReset((prev) => ({ ...prev, [id]: '' }))
flash('Password updated')
}
const inputStyle: React.CSSProperties = {
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e2e8f0',
borderRadius: '0.625rem',
padding: '0.5rem 0.75rem',
fontSize: '0.875rem',
outline: 'none',
width: '100%',
}
return (
<div className="space-y-6">
{/* Flash messages */}
{successMsg && (
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{ background: 'rgba(34,197,94,0.1)', border: '1px solid rgba(34,197,94,0.25)', color: '#86efac' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0" aria-hidden="true">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
</svg>
{successMsg}
</div>
)}
{error && (
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0" aria-hidden="true">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
{/* Header + create button */}
<div className="flex items-center justify-between">
<p className="text-sm text-slate-400">{users.length} user{users.length !== 1 ? 's' : ''}</p>
<button
onClick={() => setCreating((v) => !v)}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-slate-900 hover:brightness-110 transition-all active:scale-95"
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
Add User
</button>
</div>
{/* Create user form */}
{creating && (
<form
onSubmit={handleCreate}
className="rounded-2xl p-6 space-y-4"
style={{ background: 'rgba(34,211,238,0.04)', border: '1px solid rgba(34,211,238,0.15)' }}
>
<h3 className="text-sm font-semibold text-cyan-300">New User</h3>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-xs text-slate-400">Username</label>
<input
type="text"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
required
placeholder="username"
style={inputStyle}
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-slate-400">Password (min 8 chars)</label>
<input
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
required
placeholder="••••••••"
style={inputStyle}
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-slate-400">Role</label>
<select
value={createRole}
onChange={(e) => setCreateRole(e.target.value as Role)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={createLoading}
className="px-5 py-2 rounded-xl text-sm font-semibold text-slate-900 hover:brightness-110 transition-all disabled:opacity-60"
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
>
{createLoading ? 'Creating…' : 'Create User'}
</button>
<button
type="button"
onClick={() => setCreating(false)}
className="px-5 py-2 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
</div>
</form>
)}
{/* User list */}
<div className="space-y-3">
{users.map((user) => (
<div
key={user.id}
className="rounded-2xl p-5"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar */}
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold shrink-0"
style={{
background:
user.role === 'admin'
? 'linear-gradient(135deg, #a78bfa, #7c3aed)'
: 'linear-gradient(135deg, #22d3ee, #0891b2)',
color: '#0f172a',
}}
>
{user.username[0].toUpperCase()}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-white text-sm truncate">{user.username}</span>
{user.id === currentUserId && (
<span className="text-xs px-1.5 py-0.5 rounded-md bg-white/5 text-slate-400">you</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={
user.role === 'admin'
? { background: 'rgba(167,139,250,0.15)', color: '#c4b5fd' }
: { background: 'rgba(34,211,238,0.1)', color: '#67e8f9' }
}
>
{user.role}
</span>
<span className="text-xs text-slate-500">
Joined {new Date(user.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Delete (not self) */}
{user.id !== currentUserId && (
<button
onClick={() => handleDelete(user.id, user.username)}
className="shrink-0 p-2 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-400/10 transition-all"
title="Delete user"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path fillRule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
{/* Password reset */}
<div className="mt-4 flex gap-2 items-center">
<input
type="password"
value={pwReset[user.id] ?? ''}
onChange={(e) => setPwReset((prev) => ({ ...prev, [user.id]: e.target.value }))}
placeholder="New password (min 8 chars)"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => handlePasswordReset(user.id)}
disabled={pwLoading === user.id}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-medium text-slate-300 hover:text-white hover:bg-white/10 transition-all disabled:opacity-50 border border-white/10"
>
{pwLoading === user.id ? 'Saving…' : 'Reset pw'}
</button>
</div>
</div>
))}
{users.length === 0 && (
<p className="text-center text-slate-500 text-sm py-8">No users yet.</p>
)}
</div>
</div>
)
}