Private
Public Access
Add self-service password change, forgot-password flow, and fix admin UI bugs
Users can change their own password from the profile modal, and a "forgot password" link sends a 15-minute expiring reset link (same hashed-token pattern as site invites). The forgot-password response is always generic so it never reveals which emails are registered. Also fixes two admin-page display bugs found while testing: table row divider lines that didn't line up across a row (the actions column had `display: flex` on the <td> itself, breaking it out of normal table-cell layout -- moved to a child <div>), and the pending-invites list floating with no visual grouping (now boxed with a label and per-status badges).
This commit is contained in:
@@ -4,6 +4,8 @@ import { AdminRoute } from './components/AdminRoute'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { SignupPage } from './pages/SignupPage'
|
||||
import { ForgotPasswordPage } from './pages/ForgotPasswordPage'
|
||||
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||
import { ChatShellPage } from './pages/ChatShellPage'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
|
||||
@@ -13,6 +15,8 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
|
||||
@@ -31,6 +31,31 @@ export function removeAvatar(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return apiFetch<void>('/api/auth/password', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
export function requestPasswordReset(email: string): Promise<void> {
|
||||
return apiFetch<void>('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
|
||||
export function validateResetToken(token: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/auth/reset-password/validate?token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
export function completePasswordReset(token: string, newPassword: string): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
// Not apiFetch: that wrapper always sets Content-Type: application/json,
|
||||
// which would stomp the multipart boundary the browser needs to set itself
|
||||
// for a file upload. Mirrors api/rooms.ts's uploadRoomImage.
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
}
|
||||
|
||||
.modal input[type='text'],
|
||||
.modal input[type='password'],
|
||||
.modal input[type='email'],
|
||||
.modal textarea {
|
||||
width: 100%;
|
||||
background: var(--ds-surface-2);
|
||||
@@ -62,6 +64,8 @@
|
||||
}
|
||||
|
||||
.modal input[type='text']:focus,
|
||||
.modal input[type='password']:focus,
|
||||
.modal input[type='email']:focus,
|
||||
.modal textarea:focus {
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
@@ -78,6 +82,18 @@
|
||||
margin: -8px 0 var(--sp-3);
|
||||
}
|
||||
|
||||
.modal-success {
|
||||
color: var(--ds-accent);
|
||||
font-size: 0.82rem;
|
||||
margin: -8px 0 var(--sp-3);
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--ds-border);
|
||||
margin: var(--sp-5) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { removeAvatar, updateProfile, uploadAvatar } from '../api/auth'
|
||||
import { changePassword, removeAvatar, updateProfile, uploadAvatar } from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -19,6 +19,13 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false)
|
||||
const [savingPassword, setSavingPassword] = useState(false)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
async function handleSaveName(e: FormEvent) {
|
||||
@@ -61,6 +68,28 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setPasswordError(null)
|
||||
setPasswordSuccess(false)
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError("New passwords don't match")
|
||||
return
|
||||
}
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setPasswordSuccess(true)
|
||||
} catch (err) {
|
||||
setPasswordError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
|
||||
|
||||
return (
|
||||
@@ -123,6 +152,46 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr className="modal-divider" />
|
||||
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<div className="modal-field-label">Change password</div>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="New password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
{passwordError && <p className="modal-error">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="modal-success">Password updated.</p>}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{savingPassword ? 'Saving…' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -163,6 +163,52 @@
|
||||
background: var(--ds-void-2);
|
||||
}
|
||||
|
||||
.admin-invite-list {
|
||||
background: var(--ds-void-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.admin-invite-list-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ds-muted);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.invite-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 8px;
|
||||
text-transform: capitalize;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.invite-status-pending {
|
||||
border: 1px solid var(--ds-border);
|
||||
background: transparent;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.invite-status-accepted {
|
||||
border: 1px solid color-mix(in srgb, var(--ds-accent) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--ds-accent) 14%, transparent);
|
||||
color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.invite-status-revoked {
|
||||
border: 1px solid color-mix(in srgb, var(--ds-danger) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--ds-danger) 14%, transparent);
|
||||
color: var(--ds-danger);
|
||||
}
|
||||
|
||||
.admin-token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -368,14 +368,15 @@ export function AdminPage() {
|
||||
</div>
|
||||
|
||||
{siteInvites.length > 0 && (
|
||||
<div className="admin-token-list">
|
||||
<div className="admin-invite-list">
|
||||
<div className="admin-invite-list-label">Pending invites</div>
|
||||
{siteInvites.map((invite) => (
|
||||
<div key={invite.id} className="admin-token-row">
|
||||
<span className="admin-token-scopes">{invite.email}</span>
|
||||
<span className={`invite-status-badge invite-status-${invite.status}`}>{invite.status}</span>
|
||||
<span className="admin-token-meta">
|
||||
{invite.status}
|
||||
{invite.status === 'pending' &&
|
||||
` · expires ${new Date(invite.expires_at).toLocaleDateString()}`}
|
||||
`Expires ${new Date(invite.expires_at).toLocaleDateString()}`}
|
||||
</span>
|
||||
{invite.status === 'pending' && (
|
||||
<button
|
||||
@@ -426,24 +427,26 @@ export function AdminPage() {
|
||||
{u.is_site_admin ? 'Site admin' : 'Member'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === u.id || u.id === currentUser?.id}
|
||||
onClick={() => handleToggleActive(u)}
|
||||
>
|
||||
{u.is_active ? 'Deactivate' : 'Reactivate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === u.id || u.id === currentUser?.id}
|
||||
onClick={() => handleTogglePromote(u)}
|
||||
>
|
||||
{u.is_site_admin ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}>
|
||||
Reset password
|
||||
</button>
|
||||
<td>
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === u.id || u.id === currentUser?.id}
|
||||
onClick={() => handleToggleActive(u)}
|
||||
>
|
||||
{u.is_active ? 'Deactivate' : 'Reactivate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === u.id || u.id === currentUser?.id}
|
||||
onClick={() => handleTogglePromote(u)}
|
||||
>
|
||||
{u.is_site_admin ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}>
|
||||
Reset password
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -475,13 +478,15 @@ export function AdminPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.member_count}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
|
||||
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
|
||||
</button>
|
||||
<td>
|
||||
<div className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
|
||||
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{transferringRoomId === r.id && (
|
||||
@@ -536,10 +541,12 @@ export function AdminPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(b.created_at).toLocaleDateString()}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" onClick={() => toggleExpandBot(b.id)}>
|
||||
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
|
||||
</button>
|
||||
<td>
|
||||
<div className="admin-actions">
|
||||
<button type="button" onClick={() => toggleExpandBot(b.id)}>
|
||||
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedBotId === b.id && (
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { requestPasswordReset } from '../api/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import logo from '../assets/logo.png'
|
||||
import './LoginPage.css'
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const { user } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
if (user) return <Navigate to="/rooms" replace />
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await requestPasswordReset(email)
|
||||
} catch {
|
||||
// Fall through to the generic message regardless -- the request
|
||||
// itself never reveals whether the email is registered.
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSent(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<div className="login-brand">
|
||||
<img src={logo} alt="" />
|
||||
<span>KeepItTalking</span>
|
||||
</div>
|
||||
|
||||
{sent ? (
|
||||
<p className="login-copy">
|
||||
If an account exists for that email, a password reset link is on its way. The link
|
||||
expires in 15 minutes.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="login-copy">Enter your account email and we'll send a reset link.</p>
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/login" className="login-link">
|
||||
Back to log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -81,6 +81,19 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
font-size: 0.84rem;
|
||||
color: var(--ds-muted);
|
||||
text-decoration: none;
|
||||
margin-top: calc(-1 * var(--sp-4));
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
color: var(--ds-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
font-size: 0.86rem;
|
||||
color: var(--ds-danger);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { ApiError } from '../api/client'
|
||||
import logo from '../assets/logo.png'
|
||||
@@ -56,6 +56,9 @@ export function LoginPage() {
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
<Link to="/forgot-password" className="login-link">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { completePasswordReset, validateResetToken } from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import logo from '../assets/logo.png'
|
||||
import './LoginPage.css'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { user, updateUser } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token') ?? ''
|
||||
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setValidationError('This reset link is missing a token.')
|
||||
setChecking(false)
|
||||
return
|
||||
}
|
||||
validateResetToken(token)
|
||||
.catch((err) => {
|
||||
setValidationError(err instanceof ApiError ? err.message : 'This reset link is invalid.')
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [token])
|
||||
|
||||
if (user) return <Navigate to="/rooms" replace />
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords don't match")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const loggedInUser = await completePasswordReset(token, password)
|
||||
updateUser(loggedInUser)
|
||||
navigate('/rooms')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<div className="login-brand">
|
||||
<img src={logo} alt="" />
|
||||
<span>KeepItTalking</span>
|
||||
</div>
|
||||
|
||||
{checking && <p className="login-copy">Checking your reset link…</p>}
|
||||
|
||||
{!checking && validationError && (
|
||||
<>
|
||||
<p className="login-copy">{validationError}</p>
|
||||
<p className="login-copy">Request a new reset link and try again.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!checking && !validationError && (
|
||||
<>
|
||||
<p className="login-copy">Choose a new password for your account.</p>
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/login" className="login-link">
|
||||
Back to log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user