feat(auth): welcome email on register + password reset via Odoo mail relay

Adds the transactional email pipeline driven from the Odoo SMTP relay so
new sign-ups get a Velxio-branded welcome and existing users can reset a
forgotten password without us running our own outbound mail server.

Backend:
- PasswordResetToken model: one-time, SHA-256-hashed (plain text never on
  disk), TTL 60 min, marked used_at on consume to prevent replay.
- POST /auth/forgot-password — anti-enumeration (always 200 + generic
  message), rate-limited 3/hour/user.
- POST /auth/reset-password — verifies token, hashes new password,
  atomically marks token used.
- /auth/register hooked with asyncio.create_task to fire welcome mail —
  registration is never blocked on Odoo being up.
- New service app/services/odoo_mail.py: async httpx wrapper, fire-and-
  forget, swallows every error so the request lifecycle stays clean.
- Settings ODOO_URL / ODOO_API_KEY / ODOO_MAIL_TIMEOUT_S /
  PASSWORD_RESET_TOKEN_TTL_MINUTES / PASSWORD_RESET_RATE_LIMIT_PER_HOUR.

Frontend:
- /forgot-password page (single email field + "check your inbox" state).
- /reset-password?token=XYZ page (new password + confirmation, redirects
  to /login?reset=ok on success).
- "Forgot your password?" link + green confirmation banner on /login.
- authService gains requestPasswordReset() and resetPassword().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-12 17:34:30 -03:00
parent 71616e580d
commit 44789cf58b
12 changed files with 695 additions and 3 deletions

View File

@ -1,22 +1,56 @@
from datetime import timedelta
import asyncio
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_current_user, require_auth
from app.core.security import create_access_token, hash_password, verify_password
from app.database.session import get_db
from app.models.password_reset_token import PasswordResetToken
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
UserResponse,
)
from app.services import odoo_mail
from app.utils.geo import country_from_request
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Password reset helpers ────────────────────────────────────────────────
def _hash_token(plaintext: str) -> str:
"""SHA-256 hex of the token. Cheap one-shot hash (collision-free in
practice for 32-byte URL-safe inputs); we don't need bcrypt here
because the input itself is high-entropy random."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _ensure_aware(value: datetime) -> datetime:
"""SQLite (default DB) round-trips datetimes as naive — tag them UTC
so comparisons with `_now_utc()` don't raise."""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
@ -56,6 +90,21 @@ async def register(
token = create_access_token({"sub": user.id})
_set_auth_cookie(response, token)
# Fire-and-forget welcome mail through Odoo. Registration MUST NOT
# block on this — if Odoo is down the user still gets in. We snapshot
# the fields we need so the task isn't tied to the SQLAlchemy session.
asyncio.create_task(
odoo_mail.send_welcome(
velxio_user_id=user.id,
email=user.email,
name=user.username,
country_code=user.signup_country or None,
editor_url=f"{settings.FRONTEND_URL.rstrip('/')}/editor",
examples_url=f"{settings.FRONTEND_URL.rstrip('/')}/examples",
)
)
return user
@ -96,6 +145,123 @@ async def logout(response: Response, _user: User = Depends(require_auth)):
return {"message": "Logged out."}
# ── Password reset flow ───────────────────────────────────────────────────
_GENERIC_FORGOT_REPLY = {
"message": "If that email is registered, a reset link is on its way.",
}
@router.post("/forgot-password")
async def forgot_password(
body: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""Send a password-reset email if the address is registered.
Always returns 200 with the same generic message so a stranger can't
enumerate which emails belong to Velxio accounts. Rate-limited: at
most N=PASSWORD_RESET_RATE_LIMIT_PER_HOUR fresh tokens per user per
rolling hour. Excess attempts succeed silently (same generic 200) but
do NOT generate a token or email.
Tokens are 32-byte URL-safe random strings; only their SHA-256 hash
is persisted. The plaintext only leaves the server inside the reset
URL emailed via Odoo.
"""
email = body.email.lower()
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not user.is_active:
# Anti-enumeration: identical response shape and roughly identical
# latency. We don't sleep to fake the per-user code path — the
# bcrypt + token round-trips below are dominated by Odoo's
# network call, which only fires for real users anyway.
return _GENERIC_FORGOT_REPLY
# Rate-limit: count tokens minted in the last hour for this user.
window_start = _now_utc() - timedelta(hours=1)
count_result = await db.execute(
select(func.count(PasswordResetToken.id)).where(
PasswordResetToken.user_id == user.id,
PasswordResetToken.created_at >= window_start,
)
)
recent = count_result.scalar_one() or 0
if recent >= settings.PASSWORD_RESET_RATE_LIMIT_PER_HOUR:
logger.warning(
"[forgot-password] rate-limited user=%s email=%s recent=%s",
user.id, email, recent,
)
return _GENERIC_FORGOT_REPLY
# Mint a fresh one-time token; store only the hash.
plaintext = secrets.token_urlsafe(32)
token_row = PasswordResetToken(
user_id=user.id,
token_hash=_hash_token(plaintext),
expires_at=_now_utc() + timedelta(
minutes=settings.PASSWORD_RESET_TOKEN_TTL_MINUTES,
),
)
db.add(token_row)
await db.commit()
reset_url = (
f"{settings.FRONTEND_URL.rstrip('/')}/reset-password?token={plaintext}"
)
asyncio.create_task(
odoo_mail.send_password_reset(
email=user.email,
reset_url=reset_url,
expires_in_minutes=settings.PASSWORD_RESET_TOKEN_TTL_MINUTES,
user_name=user.username,
)
)
logger.info("[forgot-password] token minted user=%s", user.id)
return _GENERIC_FORGOT_REPLY
@router.post("/reset-password")
async def reset_password(
body: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""Consume a one-time token and set the user's new password.
Returns 400 for: token unknown, expired, or already used. We don't
distinguish those cases in the response keeps probing useless but
the server log records the exact reason.
"""
token_hash = _hash_token(body.token)
result = await db.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
)
token_row = result.scalar_one_or_none()
if not token_row:
logger.info("[reset-password] unknown token hash=%s", token_hash[:8])
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
expires_at = _ensure_aware(token_row.expires_at)
if expires_at < _now_utc():
logger.info("[reset-password] expired token id=%s", token_row.id)
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
if token_row.used_at is not None:
logger.info("[reset-password] reused token id=%s", token_row.id)
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
user_result = await db.execute(select(User).where(User.id == token_row.user_id))
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
user.hashed_password = hash_password(body.new_password)
token_row.used_at = _now_utc()
await db.commit()
logger.info("[reset-password] consumed token id=%s user=%s", token_row.id, user.id)
return {"message": "Password has been reset. You can now sign in with your new password."}
# ── Google OAuth ──────────────────────────────────────────────────────────────
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"

View File

@ -13,6 +13,29 @@ class Settings(BaseSettings):
COOKIE_SECURE: bool = False
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 days
# ── Odoo transactional-mail relay ──────────────────────────────────────
# The Velxio backend POSTs to `<ODOO_URL>/velxio/api/send-welcome` and
# `<ODOO_URL>/velxio/api/send-password-reset` on register / forgot-
# password. Calls are fire-and-forget — registration succeeds even when
# ODOO_URL is empty (no mail will be sent), so dev / CI doesn't need a
# working Odoo instance.
#
# ODOO_API_KEY must match the company-level X-Velxio-API-Key stored in
# `res.company.velxio_api_key` on the Odoo side (see
# odoo-addons/velxio_subscription/controllers/api.py).
ODOO_URL: str = ""
ODOO_API_KEY: str = ""
# How long to wait for Odoo before giving up the fire-and-forget call.
# Kept short so a stalled Odoo never holds an asyncio.create_task open
# for minutes.
ODOO_MAIL_TIMEOUT_S: float = 10.0
# Password-reset tuning. Tokens are random 32-byte URL-safe strings;
# only the SHA-256 hash is persisted so a database leak doesn't reveal
# usable reset codes.
PASSWORD_RESET_TOKEN_TTL_MINUTES: int = 60
PASSWORD_RESET_RATE_LIMIT_PER_HOUR: int = 3
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@ -26,6 +26,7 @@ from app.database.session import Base, async_engine
import app.models.user # noqa: F401
import app.models.project # noqa: F401
import app.models.usage_event # noqa: F401
import app.models.password_reset_token # noqa: F401
logger = logging.getLogger(__name__)

View File

@ -0,0 +1,48 @@
"""One-time password-reset tokens.
Each `/api/auth/forgot-password` call generates a fresh random token,
hashes it with SHA-256, and persists ONLY the hash. The plaintext goes
out via the reset URL emailed by Odoo.
Lookup by hash is constant-time-ish (SHA-256 is short enough that a
B-tree hit is dominated by I/O). Tokens are single-use: `used_at` is
stamped the moment a successful `/api/auth/reset-password` consumes
them, and any subsequent attempt to reuse the same token returns 400.
Expired tokens stay in the table a small periodic cleanup job will
prune them eventually, but they're harmless on their own (the consume
path always rechecks `expires_at > now`).
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4()),
)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False,
)
# SHA-256 hex digest of the token (64 hex chars). Plaintext NEVER stored.
token_hash: Mapped[str] = mapped_column(
String(64), unique=True, index=True, nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
)
# NULL until the token is consumed; set to the consume timestamp on
# successful reset. Re-checked on every consume to guarantee single use.
used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)

View File

@ -33,6 +33,40 @@ class LoginRequest(BaseModel):
password: str
class ForgotPasswordRequest(BaseModel):
"""Body of POST /api/auth/forgot-password.
Always responded to with 200 + a generic message (anti-enumeration);
the validator only normalises the email shape.
"""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Body of POST /api/auth/reset-password — consumes a one-time token."""
token: str
new_password: str
@field_validator("token")
@classmethod
def validate_token(cls, v: str) -> str:
v = v.strip()
if len(v) < 16:
# Tokens are 32-byte URL-safe → ~43 base64url chars. Anything
# this short is malformed; reject early so we don't even hash it.
raise ValueError("Reset token is malformed.")
return v
@field_validator("new_password")
@classmethod
def validate_new_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters.")
return v
class UserResponse(BaseModel):
id: str
username: str

View File

@ -0,0 +1,130 @@
"""Fire-and-forget bridge to Odoo's transactional-mail endpoints.
Velxio leans on Odoo's outgoing-mail relay (SPF/DKIM already warmed up,
templates editable from the admin UI) instead of running its own SMTP.
The Odoo-side endpoints live in `velxio_transactional_mail` addon:
POST <ODOO_URL>/velxio/api/send-welcome
POST <ODOO_URL>/velxio/api/send-password-reset
Both expect a JSON-RPC payload (Odoo's `type='json'` controllers expect
`{"jsonrpc": "2.0", "params": {...}}`) and authenticate via the
`X-Velxio-API-Key` header.
These helpers are designed to be called from `asyncio.create_task(...)`
so they NEVER raise into the request lifecycle. Any failure is logged at
WARNING level and the function returns False. The result is otherwise
ignored registration / forgot-password succeed even when Odoo is down,
the user just doesn't get the email until ops re-runs the cron.
"""
from __future__ import annotations
import logging
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
def _is_configured() -> bool:
return bool(settings.ODOO_URL and settings.ODOO_API_KEY)
async def _post(endpoint: str, params: dict) -> Optional[dict]:
"""POST a JSON-RPC envelope to an Odoo /velxio/api/* endpoint.
Returns the decoded `result` dict on success, None on any failure.
Never raises.
"""
if not _is_configured():
logger.info("[odoo_mail] skipped %s — ODOO_URL or ODOO_API_KEY missing", endpoint)
return None
url = settings.ODOO_URL.rstrip("/") + endpoint
payload = {"jsonrpc": "2.0", "method": "call", "params": params}
headers = {
"X-Velxio-API-Key": settings.ODOO_API_KEY,
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=settings.ODOO_MAIL_TIMEOUT_S) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
logger.warning(
"[odoo_mail] %s → HTTP %s: %s",
endpoint, response.status_code, response.text[:200],
)
return None
body = response.json()
# Odoo wraps `type='json'` controllers in {"jsonrpc": "2.0", "result": ...}
# or {"error": {...}} on failure.
if "error" in body:
logger.warning("[odoo_mail] %s → error %s", endpoint, body["error"])
return None
return body.get("result")
except httpx.TimeoutException:
logger.warning("[odoo_mail] %s timed out after %ss", endpoint, settings.ODOO_MAIL_TIMEOUT_S)
except httpx.HTTPError as exc:
logger.warning("[odoo_mail] %s → HTTP error: %s", endpoint, exc)
except Exception: # noqa: BLE001 — fire-and-forget must swallow everything
logger.exception("[odoo_mail] %s — unexpected failure", endpoint)
return None
async def send_welcome(
*,
velxio_user_id: str,
email: str,
name: str,
country_code: Optional[str] = None,
editor_url: Optional[str] = None,
examples_url: Optional[str] = None,
) -> bool:
"""Ask Odoo to send the welcome mail. Returns True iff Odoo accepted
and dispatched the mail (sent=True in the response).
Idempotent: Odoo persists `velxio_welcome_sent_at` on the partner, so
a retry of this call after a successful first dispatch is a silent
no-op on the Odoo side (returns `{sent: false, reason: "already_sent"}`).
"""
params: dict = {
"velxio_user_id": velxio_user_id,
"email": email,
"name": name,
}
if country_code:
params["country_code"] = country_code
if editor_url:
params["editor_url"] = editor_url
if examples_url:
params["examples_url"] = examples_url
result = await _post("/velxio/api/send-welcome", params)
return bool(result and result.get("sent"))
async def send_password_reset(
*,
email: str,
reset_url: str,
expires_in_minutes: int = 60,
user_name: Optional[str] = None,
) -> bool:
"""Ask Odoo to deliver a password-reset mail with the given URL.
The caller (Velxio backend) is the source of truth for the one-time
token; Odoo only renders the email.
"""
params: dict = {
"email": email,
"reset_url": reset_url,
"expires_in_minutes": expires_in_minutes,
}
if user_name:
params["user_name"] = user_name
result = await _post("/velxio/api/send-password-reset", params)
return bool(result and result.get("sent"))

View File

@ -6,6 +6,8 @@ import { ExamplesPage } from './pages/ExamplesPage';
import { DocsPage } from './pages/DocsPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { ForgotPasswordPage } from './pages/ForgotPasswordPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { UserProfilePage } from './pages/UserProfilePage';
import { ProjectPage } from './pages/ProjectPage';
import { ProjectByIdPage } from './pages/ProjectByIdPage';
@ -51,6 +53,8 @@ const ROUTES: { path: string; element: ReactElement; index?: boolean }[] = [
{ path: 'docs/:section', element: <DocsPage /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'forgot-password', element: <ForgotPasswordPage /> },
{ path: 'reset-password', element: <ResetPasswordPage /> },
{ path: 'admin', element: <AdminPage /> },
// SEO landing pages — keyword-targeted
{ path: 'circuit-simulator', element: <CircuitSimulatorPage /> },

View File

@ -223,6 +223,16 @@ body {
margin-bottom: var(--space-6);
}
.ap-success {
background: #0e2d18;
border: 1px solid #1d7f3a;
border-radius: var(--radius-sm);
color: #4ade80;
padding: 10px 14px;
font-size: 13px;
margin-bottom: var(--space-6);
}
.ap-footer {
color: var(--color-fg-muted);
font-size: 14px;

View File

@ -0,0 +1,101 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { requestPasswordReset } from '../services/authService';
import { useLocalizedHref } from '../i18n/useLocalizedNavigate';
import { useSEO } from '../utils/useSEO';
/**
* /forgot-password single email field. The backend always returns the
* same generic message regardless of whether the address is on file
* (anti-enumeration), so we show the same "check your inbox" UI either
* way. Only network errors flip us into the error state.
*/
export const ForgotPasswordPage: React.FC = () => {
const localize = useLocalizedHref();
useSEO({
title: 'Forgot password — Velxio',
description: 'Reset the password on your Velxio account.',
url: 'https://velxio.dev/forgot-password',
noindex: true,
});
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await requestPasswordReset(email);
setSubmitted(true);
} catch (err) {
// Network or server error — surface a generic message. The endpoint
// itself never returns 4xx for unknown emails (anti-enumeration).
setError("Couldn't reach the server. Please try again in a moment.");
console.error('[forgot-password]', err);
} finally {
setLoading(false);
}
};
if (submitted) {
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Check your inbox</h1>
<p className="ap-card-sub">
If an account exists for <strong>{email}</strong>, we just sent a
reset link. The link expires in 60 minutes and can only be used
once.
</p>
<p className="ap-footer">
<Link to={localize('/login')} className="ap-link">
Back to sign in
</Link>
</p>
</div>
</div>
);
}
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Forgot your password?</h1>
<p className="ap-card-sub">
Enter the email tied to your Velxio account and we'll send you a
link to choose a new password.
</p>
{error && <div className="ap-error">{error}</div>}
<form onSubmit={handleSubmit} className="ap-form">
<div className="ap-field">
<label className="ap-label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="ap-input"
autoFocus
autoComplete="email"
/>
</div>
<button type="submit" disabled={loading || !email} className="ap-btn-primary">
{loading ? 'Sending…' : 'Send reset link'}
</button>
</form>
<p className="ap-footer">
<Link to={localize('/login')} className="ap-link">
Back to sign in
</Link>
</p>
</div>
</div>
);
};

View File

@ -24,6 +24,9 @@ export const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// ?reset=ok lands here after a successful /reset-password — show a
// one-shot banner so the user knows the new password is live.
const justReset = searchParams.get('reset') === 'ok';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -47,6 +50,11 @@ export const LoginPage: React.FC = () => {
<h1 className="ap-card-title">{t('auth.login.title')}</h1>
<p className="ap-card-sub">{t('auth.login.subtitle')}</p>
{justReset && !error && (
<div className="ap-success">
Password updated. Sign in with your new password to continue.
</div>
)}
{error && <div className="ap-error">{error}</div>}
<form onSubmit={handleSubmit} className="ap-form">
@ -71,6 +79,11 @@ export const LoginPage: React.FC = () => {
className="ap-input"
/>
</div>
<div className="ap-field" style={{ textAlign: 'right' }}>
<Link to={localize('/forgot-password')} className="ap-link">
Forgot your password?
</Link>
</div>
<button type="submit" disabled={loading} className="ap-btn-primary">
{loading ? t('auth.login.signingIn') : t('auth.login.signIn')}
</button>

View File

@ -0,0 +1,136 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { resetPassword } from '../services/authService';
import { useLocalizedHref } from '../i18n/useLocalizedNavigate';
import { useSEO } from '../utils/useSEO';
/**
* /reset-password?token=XYZ lands the user here from the password-reset
* email. Two password fields with confirmation; on success we redirect to
* /login with a one-shot banner asking the user to sign in.
*
* The token is opaque on the client the backend hashes and looks it up
* server-side. We don't expose any timing-side-channel here: invalid /
* expired / used tokens all surface as the same generic 400 error from
* the backend.
*/
export const ResetPasswordPage: React.FC = () => {
const localize = useLocalizedHref();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
useSEO({
title: 'Reset password — Velxio',
description: 'Set a new password for your Velxio account.',
url: 'https://velxio.dev/reset-password',
noindex: true,
});
const [newPassword, setNewPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const hasToken = token.length >= 16;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
if (newPassword !== confirm) {
setError("Passwords don't match.");
return;
}
setLoading(true);
try {
await resetPassword(token, newPassword);
navigate(localize('/login') + '?reset=ok');
} catch (err: any) {
const detail = err?.response?.data?.detail;
setError(
detail ||
"We couldn't reset your password. The link may have expired or already been used.",
);
} finally {
setLoading(false);
}
};
if (!hasToken) {
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Invalid reset link</h1>
<p className="ap-card-sub">
This link is missing the reset token or is malformed. Request a
new one and try again.
</p>
<p className="ap-footer">
<Link to={localize('/forgot-password')} className="ap-link">
Request a new reset link
</Link>
</p>
</div>
</div>
);
}
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Choose a new password</h1>
<p className="ap-card-sub">
Pick a password at least 8 characters long. You'll be signed out of
any other sessions and asked to sign in again with the new one.
</p>
{error && <div className="ap-error">{error}</div>}
<form onSubmit={handleSubmit} className="ap-form">
<div className="ap-field">
<label className="ap-label">New password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="ap-input"
autoFocus
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="ap-field">
<label className="ap-label">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="ap-input"
autoComplete="new-password"
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading || !newPassword || !confirm}
className="ap-btn-primary"
>
{loading ? 'Resetting…' : 'Reset password'}
</button>
</form>
<p className="ap-footer">
<Link to={localize('/login')} className="ap-link">
Back to sign in
</Link>
</p>
</div>
</div>
);
};

View File

@ -31,3 +31,29 @@ export async function logout(): Promise<void> {
export function initiateGoogleLogin(): void {
window.location.href = `${API_BASE}/auth/google`;
}
/**
* Request a password-reset email. Returns the generic message string from
* the backend; the response is identical whether or not the email is
* registered (anti-enumeration), so callers should always show "check
* your inbox" regardless of the outcome.
*/
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
const { data } = await api.post<{ message: string }>('/auth/forgot-password', { email });
return data;
}
/**
* Consume a reset token and set a new password. Throws on 400 (invalid /
* expired / reused token) so the page can show a clear error.
*/
export async function resetPassword(
token: string,
newPassword: string,
): Promise<{ message: string }> {
const { data } = await api.post<{ message: string }>('/auth/reset-password', {
token,
new_password: newPassword,
});
return data;
}