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