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:
2026-08-14 21:06:17 -06:00
parent 8e3b6a16bd
commit fc96e85014
18 changed files with 836 additions and 38 deletions
+38 -3
View File
@@ -1,4 +1,4 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email) # KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email, password reset)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and direct CRUD (open and private), room roles (owner/admin/member) and direct
@@ -8,7 +8,8 @@ offline room members, a site-admin portal (user/room/bot management + an
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads in access, incoming and outgoing webhooks, message editing), image uploads in
chat messages, emoji reactions on messages, self-service user profiles chat messages, emoji reactions on messages, self-service user profiles
(display name, avatar), and admin-issued email invites for new accounts (display name, avatar), self-service password change and a token-based
forgot-password flow, and admin-issued email invites for new accounts
plus email notifications when a user is added to a room. See plus email notifications when a user is added to a room. See
`../ARCHITECTURE.md` for the full system design and the phased build plan. `../ARCHITECTURE.md` for the full system design and the phased build plan.
@@ -128,7 +129,7 @@ app/
cli.py `python -m app.cli create-user` / `generate-vapid-keys` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions, messages, message_images, message_reactions,
site_invites, smtp_settings, site_invites, password_resets, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens, push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions) webhooks_incoming, event_subscriptions)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
@@ -475,6 +476,40 @@ Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers uploads/reactions), no resend for a site invite (revoke + re-invite covers
it), no HTML email templates. it), no HTML email templates.
## Self-service password change and reset
Two related, previously-missing pieces: a logged-in user changing their own
password, and a "forgot password" flow for someone locked out.
**Change password** (`PATCH /api/auth/password`, authenticated) — takes
`current_password` + `new_password`; verifies the current one with
`security.verify_password` before setting `password_hash =
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
(profile update): mutate `current_user`, commit, done. No session
invalidation elsewhere (there's no server-side session table to invalidate
against — see Notes below), so other logged-in sessions for that account
stay valid until they expire naturally.
**Forgot password** (`app/models/password_reset.py`,
`app/services/password_service.py`) — same hashed-token-with-expiry shape as
site invites, but a shorter 15-minute lifetime (a reset link is meant to be
used immediately, unlike a signup invite someone might not open for days) and
a boolean `used` flag instead of an enum (there's no third state to track).
`POST /api/auth/forgot-password` always returns `204`, whether or not the
email matched an account — the response must never reveal which emails are
registered, so a miss is a silent no-op (no row created, no email sent) after
a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
show a "this link is invalid" state before rendering the password form.
`POST /api/auth/reset-password` completes it and — like signup — logs the
user in immediately (`request.session["user_id"]`), since they've just proven
they control the account's email.
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
documented gap as every other endpoint below, not a new one — the
unguessable expiring token is the actual protection once a request is made),
no cleanup job for expired/used `password_resets` rows (same as
`site_invites`, which has never had one either).
## Notes / scope decisions ## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are - Invite-only site registration: no `POST /api/auth/register`. Accounts are
@@ -0,0 +1,45 @@
"""password resets
Revision ID: 456cd78ca571
Revises: a3f7c2e91b4d
Create Date: 2026-08-14 20:55:22.055123
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '456cd78ca571'
down_revision: Union[str, Sequence[str], None] = 'a3f7c2e91b4d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('password_resets',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_resets_token_hash'), 'password_resets', ['token_hash'], unique=True)
op.create_index(op.f('ix_password_resets_user_id'), 'password_resets', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_password_resets_user_id'), table_name='password_resets')
op.drop_index(op.f('ix_password_resets_token_hash'), table_name='password_resets')
op.drop_table('password_resets')
# ### end Alembic commands ###
+2
View File
@@ -7,6 +7,7 @@ from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message from app.models.message import Message
from app.models.message_image import MessageImage from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction from app.models.message_reaction import MessageReaction
from app.models.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room from app.models.room import Room
from app.models.site_invite import SiteInvite from app.models.site_invite import SiteInvite
@@ -24,6 +25,7 @@ __all__ = [
"MessageImage", "MessageImage",
"MessageReaction", "MessageReaction",
"InviteStatus", "InviteStatus",
"PasswordReset",
"SiteInvite", "SiteInvite",
"SmtpSettings", "SmtpSettings",
"PushSubscription", "PushSubscription",
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
DEFAULT_RESET_LIFETIME = timedelta(minutes=15)
def _default_expires_at() -> datetime:
return datetime.now(timezone.utc) + DEFAULT_RESET_LIFETIME
class PasswordReset(Base):
__tablename__ = "password_resets"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
# Same convention as SiteInvite.token_hash / API tokens: a bearer secret
# looked up by itself, so it's hashed with security.hash_token (fast,
# deterministic sha256), not argon2.
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_default_expires_at, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
user = relationship("User")
+62 -1
View File
@@ -1,16 +1,25 @@
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import User from app.models import User
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
from app.schemas.user import ProfileUpdate, UserRead from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import ( from app.services.auth_service import (
AccountDeactivatedError, AccountDeactivatedError,
InvalidCredentialsError, InvalidCredentialsError,
authenticate_user, authenticate_user,
) )
from app.services.password_service import (
InvalidCurrentPasswordError,
PasswordResetInvalidError,
change_password,
complete_password_reset,
request_password_reset,
validate_reset_token,
)
from app.storage import ( from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES, ALLOWED_IMAGE_CONTENT_TYPES,
ImageTooLargeError, ImageTooLargeError,
@@ -120,3 +129,55 @@ async def remove_avatar(
delete_image(previous_filename) delete_image(previous_filename)
return current_user return current_user
@router.patch("/password", status_code=204)
async def change_password_endpoint(
data: PasswordChange,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
try:
await change_password(db, current_user, data.current_password, data.new_password)
except InvalidCurrentPasswordError:
raise HTTPException(status_code=400, detail="Current password is incorrect")
return Response(status_code=204)
@router.post("/forgot-password", status_code=204)
async def forgot_password_endpoint(
data: ForgotPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> Response:
# Always 204, whether or not the email matched an account -- the
# response must not reveal which emails are registered.
await request_password_reset(db, data.email, str(request.base_url))
return Response(status_code=204)
@router.get("/reset-password/validate", status_code=204)
async def validate_reset_password_endpoint(
token: str = Query(...),
db: AsyncSession = Depends(get_db),
) -> Response:
try:
await validate_reset_token(db, token)
except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
return Response(status_code=204)
@router.post("/reset-password", response_model=UserRead)
async def complete_reset_password_endpoint(
data: ResetPasswordComplete,
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
try:
user = await complete_password_reset(db, data.token, data.new_password)
except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
request.session["user_id"] = str(user.id)
return user
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel, EmailStr, Field
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=200)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordComplete(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=200)
+78
View File
@@ -0,0 +1,78 @@
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import PasswordReset, User
from app.security import hash_password, hash_token, verify_password
from app.services.email_service import send_email
class InvalidCurrentPasswordError(Exception):
pass
class PasswordResetInvalidError(Exception):
pass
async def change_password(
db: AsyncSession, user: User, current_password: str, new_password: str
) -> None:
if not verify_password(current_password, user.password_hash):
raise InvalidCurrentPasswordError()
user.password_hash = hash_password(new_password)
await db.commit()
async def request_password_reset(db: AsyncSession, email: str, base_url: str) -> None:
# Always returns normally, whether or not the email matched an account --
# the router never reveals which, to avoid leaking registered emails.
result = await db.execute(
select(User).where(User.email == email, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if user is None:
return
raw_token = secrets.token_urlsafe(32)
db.add(PasswordReset(user_id=user.id, token_hash=hash_token(raw_token)))
await db.commit()
reset_link = f"{base_url.rstrip('/')}/reset-password?token={raw_token}"
await send_email(
db,
email,
"Reset your KeepItTalking password",
f"Someone requested a password reset for this account.\n\n"
f"Reset it here:\n{reset_link}\n\n"
f"This link expires in 15 minutes. If you didn't request this, "
f"you can ignore this email.",
)
async def _get_valid_reset(db: AsyncSession, token: str) -> PasswordReset:
result = await db.execute(
select(PasswordReset).where(PasswordReset.token_hash == hash_token(token))
)
reset = result.scalar_one_or_none()
if reset is None or reset.used:
raise PasswordResetInvalidError()
if reset.expires_at <= datetime.now(timezone.utc):
raise PasswordResetInvalidError()
return reset
async def validate_reset_token(db: AsyncSession, token: str) -> None:
await _get_valid_reset(db, token)
async def complete_password_reset(db: AsyncSession, token: str, new_password: str) -> User:
reset = await _get_valid_reset(db, token)
user = await db.get(User, reset.user_id)
user.password_hash = hash_password(new_password)
reset.used = True
await db.commit()
await db.refresh(user)
return user
+163
View File
@@ -0,0 +1,163 @@
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.models import PasswordReset
from tests.conftest import register_and_login
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
monkeypatch.setattr("app.services.password_service.send_email", fake)
return calls
def _extract_token(body: str) -> str:
match = re.search(r"token=([^\s&]+)", body)
assert match, f"no token found in email body: {body}"
return match.group(1)
async def test_change_password_requires_auth(client):
resp = await client.patch(
"/api/auth/password", json={"current_password": "x", "new_password": "newpassword123"}
)
assert resp.status_code == 401
async def test_change_password_wrong_current(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.patch(
"/api/auth/password",
json={"current_password": "wrong-password", "new_password": "newpassword123"},
)
assert resp.status_code == 400
async def test_change_password_success(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.patch(
"/api/auth/password",
json={"current_password": "password123", "new_password": "newpassword123"},
)
assert resp.status_code == 204
await client.post("/api/auth/logout")
old = await client.post(
"/api/auth/login", json={"username_or_email": "alice", "password": "password123"}
)
assert old.status_code == 401
new = await client.post(
"/api/auth/login", json={"username_or_email": "alice", "password": "newpassword123"}
)
assert new.status_code == 200
async def test_change_password_too_short_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.patch(
"/api/auth/password",
json={"current_password": "password123", "new_password": "short"},
)
assert resp.status_code == 422
async def test_forgot_password_unknown_email_no_email_sent(client, monkeypatch):
calls = _fake_send_email(monkeypatch)
resp = await client.post("/api/auth/forgot-password", json={"email": "nobody@example.com"})
assert resp.status_code == 204
assert calls == []
async def test_forgot_password_known_email_sends_email(client, db_session, monkeypatch):
calls = _fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
resp = await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
assert resp.status_code == 204
assert len(calls) == 1
assert calls[0]["to"] == "alice@example.com"
async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
calls = _fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
assert validate.status_code == 204
complete = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "brandnewpass123"}
)
assert complete.status_code == 200
assert complete.json()["username"] == "alice"
# completing a reset logs the user in immediately, same as signup
me = await client.get("/api/auth/me")
assert me.status_code == 200
await client.post("/api/auth/logout")
old = await client.post(
"/api/auth/login", json={"username_or_email": "alice", "password": "password123"}
)
assert old.status_code == 401
new = await client.post(
"/api/auth/login", json={"username_or_email": "alice", "password": "brandnewpass123"}
)
assert new.status_code == 200
async def test_reset_password_invalid_token_rejected(client):
resp = await client.get("/api/auth/reset-password/validate?token=not-a-real-token")
assert resp.status_code == 400
complete = await client.post(
"/api/auth/reset-password",
json={"token": "not-a-real-token", "new_password": "newpassword123"},
)
assert complete.status_code == 400
async def test_reset_password_expired_token_rejected(client, db_session, monkeypatch):
calls = _fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
await db_session.commit()
complete = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "newpassword123"}
)
assert complete.status_code == 400
async def test_reset_password_used_token_cannot_be_reused(client, db_session, monkeypatch):
calls = _fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
first = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
)
assert first.status_code == 200
second = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "secondpass123"}
)
assert second.status_code == 400
+4
View File
@@ -4,6 +4,8 @@ import { AdminRoute } from './components/AdminRoute'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { SignupPage } from './pages/SignupPage' import { SignupPage } from './pages/SignupPage'
import { ForgotPasswordPage } from './pages/ForgotPasswordPage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { ChatShellPage } from './pages/ChatShellPage' import { ChatShellPage } from './pages/ChatShellPage'
import { AdminPage } from './pages/AdminPage' import { AdminPage } from './pages/AdminPage'
@@ -13,6 +15,8 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route <Route
path="/rooms" path="/rooms"
element={ element={
+25
View File
@@ -31,6 +31,31 @@ export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' }) 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, // Not apiFetch: that wrapper always sets Content-Type: application/json,
// which would stomp the multipart boundary the browser needs to set itself // which would stomp the multipart boundary the browser needs to set itself
// for a file upload. Mirrors api/rooms.ts's uploadRoomImage. // for a file upload. Mirrors api/rooms.ts's uploadRoomImage.
+16
View File
@@ -48,6 +48,8 @@
} }
.modal input[type='text'], .modal input[type='text'],
.modal input[type='password'],
.modal input[type='email'],
.modal textarea { .modal textarea {
width: 100%; width: 100%;
background: var(--ds-surface-2); background: var(--ds-surface-2);
@@ -62,6 +64,8 @@
} }
.modal input[type='text']:focus, .modal input[type='text']:focus,
.modal input[type='password']:focus,
.modal input[type='email']:focus,
.modal textarea:focus { .modal textarea:focus {
border-color: var(--ds-accent); border-color: var(--ds-accent);
} }
@@ -78,6 +82,18 @@
margin: -8px 0 var(--sp-3); 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 { .toggle-row {
display: flex; display: flex;
align-items: center; align-items: center;
+70 -1
View File
@@ -1,5 +1,5 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react' 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 { ApiError } from '../api/client'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -19,6 +19,13 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) 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 if (!user) return null
async function handleSaveName(e: FormEvent) { 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 const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
return ( return (
@@ -123,6 +152,46 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</button> </button>
</div> </div>
</form> </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>
</div> </div>
) )
+46
View File
@@ -163,6 +163,52 @@
background: var(--ds-void-2); 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 { .admin-token-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+13 -6
View File
@@ -368,14 +368,15 @@ export function AdminPage() {
</div> </div>
{siteInvites.length > 0 && ( {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) => ( {siteInvites.map((invite) => (
<div key={invite.id} className="admin-token-row"> <div key={invite.id} className="admin-token-row">
<span className="admin-token-scopes">{invite.email}</span> <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"> <span className="admin-token-meta">
{invite.status}
{invite.status === 'pending' && {invite.status === 'pending' &&
` · expires ${new Date(invite.expires_at).toLocaleDateString()}`} `Expires ${new Date(invite.expires_at).toLocaleDateString()}`}
</span> </span>
{invite.status === 'pending' && ( {invite.status === 'pending' && (
<button <button
@@ -426,7 +427,8 @@ export function AdminPage() {
{u.is_site_admin ? 'Site admin' : 'Member'} {u.is_site_admin ? 'Site admin' : 'Member'}
</span> </span>
</td> </td>
<td className="admin-actions"> <td>
<div className="admin-actions">
<button <button
type="button" type="button"
disabled={busyId === u.id || u.id === currentUser?.id} disabled={busyId === u.id || u.id === currentUser?.id}
@@ -444,6 +446,7 @@ export function AdminPage() {
<button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}> <button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}>
Reset password Reset password
</button> </button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -475,13 +478,15 @@ export function AdminPage() {
</span> </span>
</td> </td>
<td>{r.member_count}</td> <td>{r.member_count}</td>
<td className="admin-actions"> <td>
<div className="admin-actions">
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}> <button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'} {r.is_archived ? 'Unarchive' : 'Archive'}
</button> </button>
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}> <button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'} {transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
</button> </button>
</div>
</td> </td>
</tr> </tr>
{transferringRoomId === r.id && ( {transferringRoomId === r.id && (
@@ -536,10 +541,12 @@ export function AdminPage() {
</span> </span>
</td> </td>
<td>{new Date(b.created_at).toLocaleDateString()}</td> <td>{new Date(b.created_at).toLocaleDateString()}</td>
<td className="admin-actions"> <td>
<div className="admin-actions">
<button type="button" onClick={() => toggleExpandBot(b.id)}> <button type="button" onClick={() => toggleExpandBot(b.id)}>
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'} {expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
</button> </button>
</div>
</td> </td>
</tr> </tr>
{expandedBotId === b.id && ( {expandedBotId === b.id && (
+70
View File
@@ -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>
)
}
+13
View File
@@ -81,6 +81,19 @@
outline: none; 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 { .login-error {
font-size: 0.86rem; font-size: 0.86rem;
color: var(--ds-danger); color: var(--ds-danger);
+4 -1
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react' 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 { useAuth } from '../context/AuthContext'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import logo from '../assets/logo.png' import logo from '../assets/logo.png'
@@ -56,6 +56,9 @@ export function LoginPage() {
Log in Log in
</button> </button>
</form> </form>
<Link to="/forgot-password" className="login-link">
Forgot password?
</Link>
</div> </div>
</div> </div>
) )
+113
View File
@@ -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>
)
}