diff --git a/backend/README.md b/backend/README.md index 50969f7..d03c3a7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 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 access, incoming and outgoing webhooks, message editing), image uploads in 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 `../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` models/ SQLAlchemy models (users, rooms, room_memberships, messages, message_images, message_reactions, - site_invites, smtp_settings, + site_invites, password_resets, smtp_settings, push_subscriptions, admin_audit_log, api_tokens, webhooks_incoming, event_subscriptions) 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 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 - Invite-only site registration: no `POST /api/auth/register`. Accounts are diff --git a/backend/alembic/versions/456cd78ca571_password_resets.py b/backend/alembic/versions/456cd78ca571_password_resets.py new file mode 100644 index 0000000..951d88d --- /dev/null +++ b/backend/alembic/versions/456cd78ca571_password_resets.py @@ -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 ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8aa7884..8719b89 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.membership import RoomMembership, RoomRole from app.models.message import Message from app.models.message_image import MessageImage from app.models.message_reaction import MessageReaction +from app.models.password_reset import PasswordReset from app.models.push_subscription import PushSubscription from app.models.room import Room from app.models.site_invite import SiteInvite @@ -24,6 +25,7 @@ __all__ = [ "MessageImage", "MessageReaction", "InviteStatus", + "PasswordReset", "SiteInvite", "SmtpSettings", "PushSubscription", diff --git a/backend/app/models/password_reset.py b/backend/app/models/password_reset.py new file mode 100644 index 0000000..f4f09ae --- /dev/null +++ b/backend/app/models/password_reset.py @@ -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") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f7649eb..e6b2c3e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 app.database import get_db from app.dependencies import get_current_user from app.models import User from app.schemas.auth import LoginRequest +from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete from app.schemas.user import ProfileUpdate, UserRead from app.services.auth_service import ( AccountDeactivatedError, InvalidCredentialsError, 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 ( ALLOWED_IMAGE_CONTENT_TYPES, ImageTooLargeError, @@ -120,3 +129,55 @@ async def remove_avatar( delete_image(previous_filename) 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 diff --git a/backend/app/schemas/password.py b/backend/app/schemas/password.py new file mode 100644 index 0000000..4e6e78f --- /dev/null +++ b/backend/app/schemas/password.py @@ -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) diff --git a/backend/app/services/password_service.py b/backend/app/services/password_service.py new file mode 100644 index 0000000..e976464 --- /dev/null +++ b/backend/app/services/password_service.py @@ -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 diff --git a/backend/tests/test_password.py b/backend/tests/test_password.py new file mode 100644 index 0000000..1897337 --- /dev/null +++ b/backend/tests/test_password.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73596c2..7ebec05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + } /> + } /> { return apiFetch('/api/auth/me/avatar', { method: 'DELETE' }) } +export function changePassword(currentPassword: string, newPassword: string): Promise { + return apiFetch('/api/auth/password', { + method: 'PATCH', + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }) +} + +export function requestPasswordReset(email: string): Promise { + return apiFetch('/api/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }) +} + +export function validateResetToken(token: string): Promise { + return apiFetch(`/api/auth/reset-password/validate?token=${encodeURIComponent(token)}`) +} + +export function completePasswordReset(token: string, newPassword: string): Promise { + return apiFetch('/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. diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index d4cdea5..1eb6212 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -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; diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index fc867cf..aa21615 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -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(null) + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [passwordError, setPasswordError] = useState(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) { + +
+ +
+
Change password
+ setCurrentPassword(e.target.value)} + placeholder="Current password" + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + placeholder="New password" + autoComplete="new-password" + minLength={8} + /> + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + autoComplete="new-password" + minLength={8} + /> + {passwordError &&

{passwordError}

} + {passwordSuccess &&

Password updated.

} +
+ +
+
) diff --git a/frontend/src/pages/AdminPage.css b/frontend/src/pages/AdminPage.css index 99ef307..9be0f9c 100644 --- a/frontend/src/pages/AdminPage.css +++ b/frontend/src/pages/AdminPage.css @@ -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; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index bc5c9b8..87ddac4 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -368,14 +368,15 @@ export function AdminPage() { {siteInvites.length > 0 && ( -
+
+
Pending invites
{siteInvites.map((invite) => (
{invite.email} + {invite.status} - {invite.status} {invite.status === 'pending' && - ` · expires ${new Date(invite.expires_at).toLocaleDateString()}`} + `Expires ${new Date(invite.expires_at).toLocaleDateString()}`} {invite.status === 'pending' && ( - - + +
+ + + +
))} @@ -475,13 +478,15 @@ export function AdminPage() { {r.member_count} - - - + +
+ + +
{transferringRoomId === r.id && ( @@ -536,10 +541,12 @@ export function AdminPage() { {new Date(b.created_at).toLocaleDateString()} - - + +
+ +
{expandedBotId === b.id && ( diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..b069de0 --- /dev/null +++ b/frontend/src/pages/ForgotPasswordPage.tsx @@ -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 + + 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 ( +
+
+
+ + KeepItTalking +
+ + {sent ? ( +

+ If an account exists for that email, a password reset link is on its way. The link + expires in 15 minutes. +

+ ) : ( + <> +

Enter your account email and we'll send a reset link.

+
+ + +
+ + )} + + + Back to log in + +
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index 52ea40b..00e1485 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -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); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 6d26d64..8f69d87 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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 + + Forgot password? +
) diff --git a/frontend/src/pages/ResetPasswordPage.tsx b/frontend/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..492e13b --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.tsx @@ -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(null) + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(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 + + 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 ( +
+
+
+ + KeepItTalking +
+ + {checking &&

Checking your reset link…

} + + {!checking && validationError && ( + <> +

{validationError}

+

Request a new reset link and try again.

+ + )} + + {!checking && !validationError && ( + <> +

Choose a new password for your account.

+
+ + + {error &&

{error}

} + +
+ + )} + + + Back to log in + +
+
+ ) +}