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:
parent
71616e580d
commit
44789cf58b
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -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 /> },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue