diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 5074593c..a6e7131f 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -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" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8a5e086e..3a85ab7a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 `/velxio/api/send-welcome` and + # `/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"} diff --git a/backend/app/main.py b/backend/app/main.py index 30296edb..6bea73c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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__) diff --git a/backend/app/models/password_reset_token.py b/backend/app/models/password_reset_token.py new file mode 100644 index 00000000..d9080a83 --- /dev/null +++ b/backend/app/models/password_reset_token.py @@ -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, + ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 66cfafe6..92fee29f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/odoo_mail.py b/backend/app/services/odoo_mail.py new file mode 100644 index 00000000..5459cc7d --- /dev/null +++ b/backend/app/services/odoo_mail.py @@ -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 /velxio/api/send-welcome + POST /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")) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbb755bf..dcc04271 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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: }, { path: 'login', element: }, { path: 'register', element: }, + { path: 'forgot-password', element: }, + { path: 'reset-password', element: }, { path: 'admin', element: }, // SEO landing pages — keyword-targeted { path: 'circuit-simulator', element: }, diff --git a/frontend/src/index.css b/frontend/src/index.css index aee08240..3d8afcb9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 00000000..fb969088 --- /dev/null +++ b/frontend/src/pages/ForgotPasswordPage.tsx @@ -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 ( +
+
+

Check your inbox

+

+ If an account exists for {email}, we just sent a + reset link. The link expires in 60 minutes and can only be used + once. +

+

+ + Back to sign in + +

+
+
+ ); + } + + return ( +
+
+

Forgot your password?

+

+ Enter the email tied to your Velxio account and we'll send you a + link to choose a new password. +

+ + {error &&
{error}
} + +
+
+ + setEmail(e.target.value)} + required + className="ap-input" + autoFocus + autoComplete="email" + /> +
+ +
+ +

+ + Back to sign in + +

+
+
+ ); +}; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 58db395d..b6baae67 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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 = () => {

{t('auth.login.title')}

{t('auth.login.subtitle')}

+ {justReset && !error && ( +
+ Password updated. Sign in with your new password to continue. +
+ )} {error &&
{error}
}
@@ -71,6 +79,11 @@ export const LoginPage: React.FC = () => { className="ap-input" /> +
+ + Forgot your password? + +
diff --git a/frontend/src/pages/ResetPasswordPage.tsx b/frontend/src/pages/ResetPasswordPage.tsx new file mode 100644 index 00000000..7eff6538 --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.tsx @@ -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 ( +
+
+

Invalid reset link

+

+ This link is missing the reset token or is malformed. Request a + new one and try again. +

+

+ + Request a new reset link + +

+
+
+ ); + } + + return ( +
+
+

Choose a new password

+

+ 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. +

+ + {error &&
{error}
} + + +
+ + setNewPassword(e.target.value)} + required + className="ap-input" + autoFocus + autoComplete="new-password" + minLength={8} + /> +
+
+ + setConfirm(e.target.value)} + required + className="ap-input" + autoComplete="new-password" + minLength={8} + /> +
+ + + +

+ + Back to sign in + +

+
+
+ ); +}; diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 36ed57e7..93fe5c6f 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -31,3 +31,29 @@ export async function logout(): Promise { 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; +}