feat: complete security quality and activation milestones
This commit is contained in:
parent
5e271a3e10
commit
2f3ec2cc2e
95 changed files with 4109 additions and 459 deletions
21
.dockerignore
Normal file
21
.dockerignore
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
dist/
|
||||
build/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
39
.github/workflows/quality.yml
vendored
Normal file
39
.github/workflows/quality.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tests backend et migrations PostgreSQL
|
||||
run: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
|
||||
- name: Nettoyage environnement de test
|
||||
if: always()
|
||||
run: docker compose -p kankwa-test -f docker-compose.test.yml down -v
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tests Vitest
|
||||
run: >-
|
||||
docker run --rm
|
||||
--mount type=bind,src=$GITHUB_WORKSPACE/frontend,target=/workspace
|
||||
--workdir /workspace node:20-alpine
|
||||
sh -c "npm ci && npm run test"
|
||||
- name: E2E inscription, partage et invité
|
||||
run: >-
|
||||
docker run --rm --network host
|
||||
--mount type=bind,src=$GITHUB_WORKSPACE/frontend,target=/workspace
|
||||
--workdir /workspace mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
sh -c "npm ci && npm run test:e2e"
|
||||
- name: TypeScript, i18n, build et pré-rendu
|
||||
run: docker build --tag kankwa-frontend-ci ./frontend
|
||||
27
AGENTS.md
Normal file
27
AGENTS.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Kankwa — instructions partagées Codex / Claude Code
|
||||
|
||||
Ce fichier est le point d'entrée de Codex. Claude Code utilise `CLAUDE.md`.
|
||||
Les deux agents doivent appliquer les mêmes sources de vérité :
|
||||
|
||||
1. lire `CLAUDE.md` pour le contexte technique et les invariants du projet ;
|
||||
2. consulter par défaut `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/MEMORY.md`, puis uniquement les mémoires thématiques utiles au chantier ;
|
||||
3. lire `docs/ROADMAP.md` avant de commencer un chantier lié à l'audit ;
|
||||
4. consulter `docs/audits/2026-08-code-produit.md` pour le diagnostic et les preuves ;
|
||||
5. suivre `docs/AI_COLLABORATION.md` pour les passations et mises à jour de statut.
|
||||
|
||||
## Règle de suivi
|
||||
|
||||
- Le code et les migrations font foi sur le comportement réel.
|
||||
- La mémoire `.claude` apporte le contexte historique ; en cas de divergence, le code et les documents versionnés les plus récents prévalent.
|
||||
- `docs/ROADMAP.md` est la source unique pour l'avancement de l'audit.
|
||||
- Ne jamais marquer un item `DONE` sans satisfaire ses critères d'acceptation et
|
||||
inscrire une preuve vérifiable dans la roadmap (tests, commit, PR ou fichiers).
|
||||
- Mettre à jour la documentation dans la même modification que le code concerné.
|
||||
- Ne jamais copier de secret ou le contenu de `.env` dans la documentation, les
|
||||
prompts, les logs ou les commits.
|
||||
|
||||
## Priorité actuelle
|
||||
|
||||
Tant que le jalon `G0 — sécurité critique` de `docs/ROADMAP.md` n'est pas validé,
|
||||
les correctifs P0 priment sur la refonte marketing, le SEO et les nouvelles
|
||||
fonctionnalités.
|
||||
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -1,5 +1,22 @@
|
|||
# Kankwa — Contexte projet
|
||||
|
||||
## Travail croisé Codex / Claude Code
|
||||
|
||||
Les deux agents utilisent les mêmes sources de vérité :
|
||||
|
||||
- `AGENTS.md` — point d'entrée Codex et règles communes ;
|
||||
- `docs/audits/2026-08-code-produit.md` — audit daté et preuves ;
|
||||
- `docs/ROADMAP.md` — source unique des priorités et de l'avancement ;
|
||||
- `docs/AI_COLLABORATION.md` — protocole de prise en charge et de passation.
|
||||
|
||||
Avant tout chantier issu de l'audit, lire la roadmap et passer l'item concerné à
|
||||
`IN_PROGRESS`. Ne le marquer `DONE` qu'avec ses critères d'acceptation satisfaits
|
||||
et des preuves renseignées. Mettre à jour la roadmap et la documentation technique
|
||||
dans la même modification que le code.
|
||||
|
||||
**Priorité active :** le jalon G0 sécurité critique précède la refonte marketing,
|
||||
le SEO, la growth et toute nouvelle fonctionnalité.
|
||||
|
||||
Plateforme de services du quotidien (wishlist, sondages, Secret Santa, partage de frais...).
|
||||
Alternatives éthiques aux outils grand public, hébergées sur serveur privé, RGPD-friendly.
|
||||
Modèle : abonnement Premium + freemium (3 services actifs max).
|
||||
|
|
@ -81,6 +98,13 @@ kankwa/
|
|||
- **Pages privées** : toute page accessible par lien de partage doit passer `noindex: true` à `usePageMeta`, + header `X-Robots-Tag` côté Caddy (cf. `Caddyfile.example`) pour les crawlers sans JS.
|
||||
- Le sitemap est servi par l'**API** (`/sitemap.xml`), pas en statique — Caddy route ce chemin vers `kankwa-api:8000`.
|
||||
|
||||
## Activation produit
|
||||
|
||||
- Le CTA public principal est « Créer mon événement » et cible `/hub/new` après authentification. La destination interne `next` doit être conservée dans tous les flux (mot de passe, code email et magic link) et validée comme chemin local pour empêcher les redirections ouvertes.
|
||||
- Un Hub nouvellement créé ouvre `/hub/:id?onboarding=1` avec une checklist création → premier module → partage. Ne pas retirer l’accès direct aux actions de module ni au lien public.
|
||||
- `/demo` est la démonstration publique bilingue, sans compte ni données artificielles persistées ; la route appartient à `seo-routes.json` et doit rester pré-rendue.
|
||||
- `GuestConversionCTA` est la source commune du CTA invité→organisateur. Il s’affiche uniquement après la réussite de l’action invitée et jamais pour un utilisateur connecté.
|
||||
|
||||
---
|
||||
|
||||
## i18n bilingue FR/EN
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
tondomaine.com {
|
||||
encode zstd gzip
|
||||
# Défense en profondeur : mêmes politiques que l’API, appliquées aussi au SPA.
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), geolocation=(), microphone=()"
|
||||
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com https://r.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; upgrade-insecure-requests"
|
||||
}
|
||||
|
||||
# Le sitemap est généré par l'API (routes + articles), pas servi en statique.
|
||||
handle /sitemap.xml {
|
||||
reverse_proxy localhost:8000
|
||||
reverse_proxy kankwa-api:8000
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:8000
|
||||
reverse_proxy kankwa-api:8000
|
||||
}
|
||||
|
||||
# Anciens slugs indexés : 301 réelle plutôt que la redirection React côté client
|
||||
|
|
@ -22,6 +31,6 @@ tondomaine.com {
|
|||
header @private X-Robots-Tag "noindex, nofollow"
|
||||
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
reverse_proxy kankwa-frontend:3001
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -1,17 +1,20 @@
|
|||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim@sha256:229a2c5bfa27522db7815ea81f9bed70af17ccb9de9fc7ad142b1877b5830d36
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system app \
|
||||
&& useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
COPY . .
|
||||
COPY --chown=app:app . .
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
12
api/main.py
12
api/main.py
|
|
@ -64,6 +64,18 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
||||
if request.url.path.startswith("/api/auth/"):
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
|
@ -12,12 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from api.config import settings
|
||||
from models.user import User
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.premium_check import is_premium_active
|
||||
from shared.auth.jwt import create_access_token, create_email_change_token, decode_email_change_token
|
||||
from shared.auth.magic_link import create_magic_token, verify_magic_token
|
||||
from shared.auth.verification_code import create_verification_code, verify_code
|
||||
from shared.auth.password import hash_password, verify_password
|
||||
from shared.auth.password import hash_password, validate_password_strength, verify_password
|
||||
from shared.database.connection import get_db
|
||||
from shared.email import sender as email_sender
|
||||
from shared.payments import stripe_client
|
||||
from shared.email.sender import safe_send
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -34,20 +36,7 @@ class RegisterRequest(BaseModel):
|
|||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
errors = []
|
||||
if len(v) < 8:
|
||||
errors.append("8 caractères minimum")
|
||||
if not re.search(r'[A-Z]', v):
|
||||
errors.append("une majuscule")
|
||||
if not re.search(r'[a-z]', v):
|
||||
errors.append("une minuscule")
|
||||
if not re.search(r'\d', v):
|
||||
errors.append("un chiffre")
|
||||
if not re.search(r'[^A-Za-z0-9]', v):
|
||||
errors.append("un caractère spécial")
|
||||
if errors:
|
||||
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
|
@ -57,6 +46,14 @@ class LoginRequest(BaseModel):
|
|||
|
||||
class MagicLinkRequest(BaseModel):
|
||||
email: EmailStr
|
||||
next: str = Field(default="/hub/new", max_length=500)
|
||||
|
||||
@field_validator("next")
|
||||
@classmethod
|
||||
def safe_next_path(cls, value: str) -> str:
|
||||
if not value.startswith("/") or value.startswith("//"):
|
||||
raise ValueError("next must be an internal path")
|
||||
return value
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
|
|
@ -94,6 +91,10 @@ class TokenResponse(BaseModel):
|
|||
user: UserResponse
|
||||
|
||||
|
||||
def _user_response(user: User) -> UserResponse:
|
||||
return UserResponse.model_validate(user).model_copy(update={"is_premium": is_premium_active(user)})
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
|
@ -108,9 +109,7 @@ class SetPasswordRequest(BaseModel):
|
|||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Le mot de passe doit faire au moins 8 caractères")
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -121,10 +120,11 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
if user.password_hash and user.email_verified:
|
||||
# Une adresse vérifiée peut provenir d’un magic link ou d’une invitation :
|
||||
# ne jamais permettre à une simple réinscription de prendre le compte.
|
||||
if user.email_verified:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email_already_used")
|
||||
user.password_hash = hash_password(body.password)
|
||||
await db.commit()
|
||||
# Le mot de passe initial n’est pas réécrit lors d’une relance d’inscription.
|
||||
else:
|
||||
user = User(email=body.email, password_hash=hash_password(body.password), lang=body.lang)
|
||||
db.add(user)
|
||||
|
|
@ -146,14 +146,14 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||
code = await create_verification_code(user.email, db)
|
||||
await safe_send(email_sender.send_email_verification(user.email, code, lang=user.lang), logger)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified")
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
|
||||
|
||||
|
||||
@router.post("/magic-link", response_model=MessageResponse)
|
||||
async def request_magic_link(body: MagicLinkRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = (await db.execute(select(User).where(User.email == body.email))).scalar_one_or_none()
|
||||
token = await create_magic_token(body.email, db)
|
||||
magic_url = f"{settings.frontend_url}/magic?token={token}"
|
||||
magic_url = f"{settings.frontend_url}/magic?{urlencode({'token': token, 'next': body.next})}"
|
||||
if settings.environment != "production":
|
||||
logger.info("DEV — magic link pour %s : %s", body.email, magic_url)
|
||||
await safe_send(email_sender.send_magic_link(body.email, magic_url, lang=existing.lang if existing else "fr"), logger)
|
||||
|
|
@ -183,7 +183,7 @@ async def verify_magic_link(token: str, lang: Literal["fr", "en"] = "fr", db: As
|
|||
set_password_url = f"{settings.frontend_url}/magic?token={set_password_token}&next=/set-password"
|
||||
await safe_send(email_sender.send_welcome_email(email, set_password_url, lang=user.lang), logger)
|
||||
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=TokenResponse)
|
||||
|
|
@ -193,13 +193,14 @@ async def verify_email(body: VerifyEmailRequest, db: AsyncSession = Depends(get_
|
|||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="account_not_found")
|
||||
if user.email_verified:
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
# Un code est à usage unique : un compte déjà vérifié ne reçoit jamais de JWT ici.
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_already_verified")
|
||||
ok = await verify_code(user.email, body.code, db)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_code")
|
||||
user.email_verified = True
|
||||
await db.commit()
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
|
||||
|
||||
|
||||
@router.post("/resend-verification", response_model=MessageResponse)
|
||||
|
|
@ -233,7 +234,7 @@ async def set_password(
|
|||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
return UserResponse.model_validate(current_user)
|
||||
return _user_response(current_user)
|
||||
|
||||
|
||||
@router.patch("/lang", response_model=MessageResponse)
|
||||
|
|
@ -281,7 +282,7 @@ async def me_usage(current_user: User = Depends(get_current_user), db: AsyncSess
|
|||
"services": service_data,
|
||||
"total": total,
|
||||
"max": settings.max_free_active_services,
|
||||
"is_premium": current_user.is_premium,
|
||||
"is_premium": is_premium_active(current_user),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -358,7 +359,7 @@ async def confirm_email_change(token: str, db: AsyncSession = Depends(get_db)):
|
|||
user.email = new_email
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
|
||||
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
|
|
@ -373,6 +374,20 @@ async def delete_account(
|
|||
):
|
||||
if body.confirmation not in ("SUPPRIMER", "DELETE"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="confirmation_incorrect")
|
||||
try:
|
||||
# Stripe est annulé avant l’effacement local afin de ne jamais perdre la
|
||||
# possibilité de stopper une facturation active. La fonction est idempotente.
|
||||
await stripe_client.cancel_active_subscriptions(
|
||||
customer_id=current_user.stripe_customer_id,
|
||||
known_subscription_id=current_user.stripe_subscription_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Annulation Stripe échouée avant suppression user=%s", current_user.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="subscription_cancellation_failed",
|
||||
)
|
||||
|
||||
await db.delete(current_user)
|
||||
await db.commit()
|
||||
return MessageResponse(message="Compte supprimé")
|
||||
return MessageResponse(message="Compte et abonnement supprimés")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from models.user import User
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.premium_check import is_premium_active
|
||||
from shared.database.connection import get_db
|
||||
from shared.email import sender as email_sender
|
||||
from shared.email.sender import safe_send
|
||||
|
|
@ -56,7 +57,7 @@ async def create_checkout(
|
|||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.is_premium:
|
||||
if is_premium_active(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Abonnement déjà actif")
|
||||
price_id = stripe_client.PRICE_BY_PLAN.get(body.plan)
|
||||
if not price_id:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import html as html_lib
|
||||
import ipaddress
|
||||
import json
|
||||
|
|
@ -12,7 +13,7 @@ import httpx
|
|||
from bs4 import BeautifulSoup, Tag
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from price_parser import Price
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -112,8 +113,8 @@ class ScrapeResult(BaseModel):
|
|||
|
||||
|
||||
class ParseRequest(BaseModel):
|
||||
url: str
|
||||
html: str
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
html: str = Field(min_length=1, max_length=2_000_000)
|
||||
|
||||
|
||||
# ── Price parsing (price-parser) ───────────────────────────────────────────────
|
||||
|
|
@ -597,51 +598,86 @@ async def _enrich_with_affiliates(url: str, result: ScrapeResult) -> ScrapeResul
|
|||
# ── HTML fetcher (FlareSolverr + fallback httpx) ──────────────────────────────
|
||||
|
||||
_FLARESOLVERR_URL = "http://kankwa-scraper:8191/v1"
|
||||
_MAX_HTML_BYTES = 2_000_000
|
||||
_MAX_REDIRECTS = 3
|
||||
_scrape_semaphore = asyncio.Semaphore(4)
|
||||
|
||||
|
||||
def _ensure_payload_size(payload: str) -> str | None:
|
||||
if len(payload.encode("utf-8")) > _MAX_HTML_BYTES:
|
||||
logger.warning("Réponse scraper refusée : payload trop volumineux")
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_limited_response(response: httpx.Response) -> str | None:
|
||||
declared_size = response.headers.get("content-length")
|
||||
if declared_size and declared_size.isdigit() and int(declared_size) > _MAX_HTML_BYTES:
|
||||
logger.warning("Réponse httpx refusée : Content-Length trop volumineux")
|
||||
return None
|
||||
payload = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
payload.extend(chunk)
|
||||
if len(payload) > _MAX_HTML_BYTES:
|
||||
logger.warning("Réponse httpx refusée : payload trop volumineux")
|
||||
return None
|
||||
return payload.decode(response.encoding or "utf-8", errors="replace")
|
||||
|
||||
|
||||
async def _fetch_html(url: str) -> str | None:
|
||||
"""
|
||||
Fetch via FlareSolverr (JS rendering + bypass Cloudflare).
|
||||
Fallback httpx si FlareSolverr est indisponible.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
_FLARESOLVERR_URL,
|
||||
json={"cmd": "request.get", "url": url, "maxTimeout": 45000},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") == "ok":
|
||||
html = data["solution"]["response"]
|
||||
logger.info("FlareSolverr OK %s (%d bytes)", url, len(html))
|
||||
return html
|
||||
logger.warning("FlareSolverr status=%s %s", data.get("status"), url)
|
||||
except Exception as e:
|
||||
logger.warning("FlareSolverr unavailable (%s), fallback httpx: %s", url, e)
|
||||
|
||||
# Fallback httpx — pour les sites sans anti-bot ou si FlareSolverr est down.
|
||||
# Redirections suivies manuellement pour revalider chaque cible (anti-SSRF par redirect).
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=False, timeout=15) as client:
|
||||
current = url
|
||||
for _ in range(5):
|
||||
resp = await client.get(current, headers=HEADERS)
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
break
|
||||
current = str(resp.url.join(location))
|
||||
_validate_url(current) # lève si la redirection pointe vers l'interne
|
||||
continue
|
||||
"""Récupère du HTML sous limites de temps, taille et concurrence."""
|
||||
async with _scrape_semaphore:
|
||||
try:
|
||||
timeout = httpx.Timeout(35.0, connect=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
_FLARESOLVERR_URL,
|
||||
json={"cmd": "request.get", "url": url, "maxTimeout": 30_000},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info("httpx fallback OK %s", url)
|
||||
return resp.text
|
||||
logger.warning("httpx fallback : trop de redirections %s", url)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("httpx fallback failed %s: %s", url, e)
|
||||
return None
|
||||
data = resp.json()
|
||||
solution = data.get("solution") if isinstance(data, dict) else None
|
||||
if data.get("status") == "ok" and isinstance(solution, dict):
|
||||
final_url = solution.get("url") or solution.get("responseUrl")
|
||||
if final_url:
|
||||
try:
|
||||
_validate_url(str(final_url))
|
||||
except HTTPException:
|
||||
logger.warning("FlareSolverr final URL refused for %s", url)
|
||||
return None
|
||||
html = solution.get("response")
|
||||
if isinstance(html, str):
|
||||
bounded_html = _ensure_payload_size(html)
|
||||
if bounded_html is not None:
|
||||
logger.info("FlareSolverr OK %s (%d bytes)", url, len(bounded_html.encode("utf-8")))
|
||||
return bounded_html
|
||||
logger.warning("FlareSolverr response invalid for %s", url)
|
||||
except Exception as exc:
|
||||
logger.warning("FlareSolverr unavailable (%s), fallback httpx: %s", url, exc)
|
||||
|
||||
# Redirections manuelles : chaque URL est résolue et validée contre SSRF.
|
||||
try:
|
||||
timeout = httpx.Timeout(15.0, connect=5.0)
|
||||
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
async with client.stream("GET", current, headers=HEADERS) as resp:
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
return None
|
||||
current = str(resp.url.join(location))
|
||||
_validate_url(current)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
html = await _read_limited_response(resp)
|
||||
if html is not None:
|
||||
logger.info("httpx fallback OK %s", url)
|
||||
return html
|
||||
logger.warning("httpx fallback : trop de redirections %s", url)
|
||||
except Exception as exc:
|
||||
logger.warning("httpx fallback failed %s: %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -663,7 +699,7 @@ async def get_affiliate_programs():
|
|||
|
||||
|
||||
@router.get("/scrape", response_model=ScrapeResult, dependencies=[Depends(get_current_user)])
|
||||
async def scrape_url(url: str = Query(...)):
|
||||
async def scrape_url(url: str = Query(..., min_length=1, max_length=2048)):
|
||||
_validate_url(url)
|
||||
html = await _fetch_html(url)
|
||||
if not html:
|
||||
|
|
|
|||
36
docker-compose.test.yml
Normal file
36
docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: kankwa-test
|
||||
|
||||
services:
|
||||
db-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: kankwa_test
|
||||
POSTGRES_PASSWORD: kankwa_test_password
|
||||
POSTGRES_DB: kankwa_test
|
||||
POSTGRES_HOST_AUTH_METHOD: scram-sha-256
|
||||
command: postgres -c password_encryption=scram-sha-256
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kankwa_test -d kankwa_test"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
tests:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://kankwa_test:kankwa_test_password@db-test:5432/kankwa_test
|
||||
SECRET_KEY: test-only-secret-key-not-for-production
|
||||
ENVIRONMENT: test
|
||||
FRONTEND_URL: http://testserver
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
HOME: /tmp
|
||||
PATH: /tmp/.local/bin:$PATH
|
||||
depends_on:
|
||||
db-test:
|
||||
condition: service_healthy
|
||||
command: >-
|
||||
sh -c "python -m pip install --no-cache-dir --user -r requirements-dev.txt &&
|
||||
python -m alembic upgrade head &&
|
||||
python -m pytest -q -p no:cacheprovider tests"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
|
||||
container_name: kankwa-db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
|
|
@ -13,6 +13,7 @@ services:
|
|||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
|
|
@ -29,15 +30,25 @@ services:
|
|||
PYTHONPATH: /app
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
scraper:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
image: ghcr.io/flaresolverr/flaresolverr@sha256:139dfee1c6f89249c8d665d1333a42e8ec74ec0a86bc6bb1c8461e10d3a66a47
|
||||
container_name: kankwa-scraper
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8191/ >/dev/null"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
|
@ -47,6 +58,11 @@ services:
|
|||
container_name: kankwa-frontend
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
|
|
|||
65
docs/AI_COLLABORATION.md
Normal file
65
docs/AI_COLLABORATION.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Collaboration Codex / Claude Code
|
||||
|
||||
Ce document définit le protocole commun pour qu'un chantier commencé avec un
|
||||
agent puisse être repris sans perte de contexte par l'autre.
|
||||
|
||||
## Sources de vérité
|
||||
|
||||
| Sujet | Source canonique |
|
||||
|---|---|
|
||||
| Comportement applicatif | Code, migrations et tests |
|
||||
| Architecture et invariants | `CLAUDE.md` |
|
||||
| Audit daté | `docs/audits/2026-08-code-produit.md` |
|
||||
| Avancement et priorités | `docs/ROADMAP.md` |
|
||||
| Mémoire Claude Code | `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/` |
|
||||
|
||||
La mémoire Claude Code sert d'index et de contexte court. Elle ne doit pas
|
||||
dupliquer la roadmap complète : les documents versionnés dans Git restent la
|
||||
référence commune.
|
||||
|
||||
## Début d'un chantier
|
||||
|
||||
1. Lire `git status` et préserver les modifications de l'autre agent.
|
||||
2. Lire l'item de roadmap visé, ses dépendances et ses critères d'acceptation.
|
||||
3. Passer son statut à `IN_PROGRESS` et renseigner `Agent` et `Début`.
|
||||
4. Ajouter une courte note dans le journal si une hypothèse ou décision est prise.
|
||||
5. Ne traiter qu'un périmètre testable ; créer un nouvel item si le besoin dépasse
|
||||
sensiblement le périmètre initial.
|
||||
|
||||
## Fin ou passation
|
||||
|
||||
Mettre à jour l'item avec :
|
||||
|
||||
- le statut réel : `DONE`, `BLOCKED` ou `TODO` si le travail est abandonné ;
|
||||
- les fichiers modifiés ;
|
||||
- les commandes de validation exécutées et leur résultat ;
|
||||
- les limites de validation de l'environnement ;
|
||||
- la prochaine action précise si l'item n'est pas terminé.
|
||||
|
||||
Une implémentation sans test requis, ou dont le test n'a pas pu être exécuté,
|
||||
reste `IN_PROGRESS` ou `BLOCKED` selon la cause. Elle n'est pas `DONE`.
|
||||
|
||||
## Statuts autorisés
|
||||
|
||||
| Statut | Signification |
|
||||
|---|---|
|
||||
| `TODO` | Prêt mais non commencé |
|
||||
| `IN_PROGRESS` | Travail actif, un agent identifié |
|
||||
| `BLOCKED` | Bloqué avec cause et condition de reprise documentées |
|
||||
| `DONE` | Critères d'acceptation satisfaits et preuves renseignées |
|
||||
| `WONT_DO` | Abandon explicite avec décision documentée |
|
||||
|
||||
## Décisions
|
||||
|
||||
Toute décision qui modifie la sécurité, les données, le contrat API, le SEO,
|
||||
l'infrastructure ou un invariant transverse doit être inscrite dans le journal de
|
||||
`docs/ROADMAP.md` et répercutée dans `CLAUDE.md` ou la mémoire thématique adaptée.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Ne jamais publier de procédure d'exploitation détaillée dans une interface
|
||||
utilisateur ou un contenu public.
|
||||
- Ne jamais lire ou reproduire les valeurs de `.env` si les noms des variables
|
||||
suffisent.
|
||||
- Si une image Docker contenant potentiellement `.env` a été distribuée, considérer
|
||||
les secrets comme compromis et documenter leur rotation sans consigner leur valeur.
|
||||
286
docs/ROADMAP.md
Normal file
286
docs/ROADMAP.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# Roadmap de remédiation et de croissance
|
||||
|
||||
Dernière mise à jour : 10 août 2026
|
||||
Source : `docs/audits/2026-08-code-produit.md`
|
||||
|
||||
Ce fichier est le tableau de suivi commun à Codex et Claude Code. Mettre à jour les
|
||||
statuts, preuves et décisions dans la même modification que le code.
|
||||
|
||||
## Vue des jalons
|
||||
|
||||
| Jalon | Objectif | État | Condition de sortie |
|
||||
|---|---|---|---|
|
||||
| G0 | Sécurité critique | `DONE` | Tous les P0 corrigés et testés ; risque externe explicitement accepté |
|
||||
| G1 | Socle qualité et exploitation | `DONE` | CI verte, tests critiques, sauvegarde restaurable |
|
||||
| G2 | Activation produit | `DONE` | Parcours CTA → Hub → module → partage validé |
|
||||
| G3 | Mesure first-party | `TODO` | Funnel et cycle de vie mesurables sans PII inutile |
|
||||
| G4 | Refonte marketing | `TODO` | Homepage orientée scénario et démonstration réelle |
|
||||
| G5 | Fondations SEO corrigées | `TODO` | Canonical/hreflang/JSON-LD testés sur FR et EN |
|
||||
| G6 | Acquisition SEO | `TODO` | Premier cluster de pages utiles indexable |
|
||||
| G7 | Boucles de croissance | `TODO` | Conversion invité→organisateur mesurée et optimisée |
|
||||
|
||||
## G0 — Sécurité critique
|
||||
|
||||
### P0-SEC-01 — corriger `/verify-email`
|
||||
|
||||
- Statut : `DONE`
|
||||
- Agent : Codex
|
||||
- Début : 10 août 2026
|
||||
- Dépendances : aucune
|
||||
- Travail : toujours valider le code avant émission du JWT ; réponse sûre pour un
|
||||
compte déjà vérifié ; invalider les codes consommés.
|
||||
- Acceptation : tests compte vérifié + code faux, code expiré, code réutilisé et
|
||||
code valide ; aucun token n'est renvoyé dans les trois premiers cas.
|
||||
- Preuves : `api/routers/auth.py`, `tests/test_auth_security.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
|
||||
|
||||
### P0-SEC-02 — protéger les comptes magic-link/passwordless
|
||||
|
||||
- Statut : `DONE`
|
||||
- Agent : Codex
|
||||
- Début : 10 août 2026
|
||||
- Dépendances : P0-SEC-01
|
||||
- Travail : refuser la réinscription de tout compte vérifié ; réserver la définition
|
||||
initiale du mot de passe à une session authentifiée ; mutualiser sa validation.
|
||||
- Acceptation : impossible de définir ou remplacer un mot de passe avec seulement
|
||||
une adresse email ; tests inscription, magic link, invitation et set-password.
|
||||
- Preuves : `shared/auth/password.py`, `api/routers/auth.py`, `tests/test_auth_security.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
|
||||
|
||||
### P0-SEC-03 — séparer les schémas publics Kount
|
||||
|
||||
- Statut : `DONE`
|
||||
- Agent : Codex
|
||||
- Début : 10 août 2026
|
||||
- Dépendances : aucune
|
||||
- Travail : créer des schémas publics sans email ni `cancel_token` et réserver les
|
||||
données de gestion aux réponses owner/co-owner.
|
||||
- Acceptation : test de contrat sur `GET /groups/shared/{token}` ; recherche JSON
|
||||
confirmant l'absence d'email et de tout jeton d'annulation ; annulation toujours
|
||||
possible par le créateur via le token reçu à la création.
|
||||
- Preuves : `services/kount/{schemas,service,router}.py`, `tests/test_kount_public_contract.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
|
||||
|
||||
### P0-INFRA-01 — exclure les secrets des images Docker
|
||||
|
||||
- Statut : `DONE`
|
||||
- Agent : Codex
|
||||
- Début : 10 août 2026
|
||||
- Dépendances : aucune
|
||||
- Travail : ajouter `.dockerignore`, réduire les contextes et inspecter les images
|
||||
existantes sans afficher les secrets.
|
||||
- Acceptation : `.env`, `.git`, caches et `node_modules` absents du contexte/image ;
|
||||
décision de rotation consignée si une image potentiellement contaminée a été
|
||||
distribuée.
|
||||
- Preuves : `.dockerignore`, `frontend/.dockerignore` ; images `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit` construites et inspectées sans `.env`, `.git`, caches ni `node_modules` ; rotation locale appliquée le 10 août 2026 ; rotations fournisseur non effectuées, risque explicitement accepté : `docs/runbooks/secret-rotation.md`.
|
||||
- Décision de risque : le propriétaire accepte explicitement le risque résiduel et choisit de ne pas révoquer Stripe, SMTP ni TradeDoubler ; ces rotations ne conditionnent plus la clôture de P0-INFRA-01.
|
||||
|
||||
|
||||
### P0-BILL-01 — suppression de compte et abonnement Stripe
|
||||
|
||||
- Statut : `DONE`
|
||||
- Agent : Codex
|
||||
- Début : 10 août 2026
|
||||
- Dépendances : aucune
|
||||
- Travail : définir puis implémenter la politique d'annulation avant effacement,
|
||||
avec idempotence et message utilisateur explicite.
|
||||
- Acceptation : tests webhook/API ; aucun abonnement actif orphelin après suppression ;
|
||||
politique reflétée dans les CGU et la confidentialité.
|
||||
- Preuves : `shared/payments/stripe_client.py`, `api/routers/auth.py`, CGU/confidentialité et `tests/test_billing_deletion.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
|
||||
|
||||
## G1 — Socle qualité et exploitation
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| QLT-01 | Installer Pytest + PostgreSQL de test | `DONE` | Auth, droits et routes publiques testables | `docker-compose.test.yml`, `tests/integration/test_postgres_flows.py` ; migrations Alembic + 19 passed (10 août 2026) |
|
||||
| QLT-02 | Installer Vitest/Testing Library | `DONE` | Hooks et composants critiques couverts | `vitest.config.ts`, 4 tests Vitest verts (10 août 2026) |
|
||||
| QLT-03 | Ajouter Playwright | `DONE` | E2E inscription→partage→invité | `frontend/e2e/critical-flow.spec.ts`, Playwright Chromium : 1 passed (10 août 2026) |
|
||||
| QLT-04 | Ajouter CI | `DONE` | Syntaxe, types, i18n, tests, build et migrations verts | `.github/workflows/quality.yml` ; backend, Vitest, Playwright et build Docker câblés (10 août 2026) |
|
||||
| SEC-04 | Durcir sessions et headers | `DONE` | CSP/headers ; stratégie HttpOnly documentée et testée | `api/main.py`, `Caddyfile.example`, décision session et tests headers ; 23 Pytest verts (10 août 2026) |
|
||||
| SEC-05 | Refaire le rate limiting | `DONE` | Clés normalisées, limites globales, stockage borné | `shared/rate_limit/middleware.py`, `tests/test_g1_security.py` ; 23 Pytest verts (10 août 2026) |
|
||||
| SEC-06 | Isoler et borner le scraper | `DONE` | Redirects/egress/payload/concurrence contrôlés | `api/routers/scrape.py`, `tests/test_g1_security.py` ; 23 Pytest verts (10 août 2026) |
|
||||
| DATA-01 | Harmoniser validation API/SQL | `DONE` | Entrées invalides → 422, jamais 500 SQL | `shared/validation.py`, schémas des services, `tests/test_input_validation.py` ; 36 Pytest verts (10 août 2026) |
|
||||
| DATA-02 | Traiter les courses métier | `DONE` | Limite freemium et collectes atomiques | verrous SQL dans `shared/auth/premium_check.py` et `services/kdo/router.py` ; concurrence PostgreSQL testée, 36 Pytest verts (10 août 2026) |
|
||||
| BILL-02 | Unifier Premium effectif | `DONE` | API et UI utilisent la même fonction d’état | `is_premium_active`, réponses auth, checkout et dépendance Premium ; `tests/test_premium_effective.py`, 36 Pytest verts (10 août 2026) |
|
||||
| OPS-01 | Corriger et durcir Docker/Caddy | `DONE` | Ports cohérents, images figées, non-root, healthchecks | `Dockerfile`, `frontend/Dockerfile`, `docker-compose.yml`, `Caddyfile.example` ; builds `g1`, users 999/100, Compose et Caddy valides (10 août 2026) |
|
||||
| OPS-02 | Sauvegarde/restauration PostgreSQL | `DONE` | Restauration testée et datée | Runbook + scripts ; restauration PostgreSQL isolée au niveau Alembic `0026` (10 août 2026) |
|
||||
| FE-01 | Ajouter un ErrorBoundary | `DONE` | Erreur de chunk/rendu récupérable | `ErrorBoundary.tsx`, `App.tsx` ; Vitest, i18n et TypeScript verts (10 août 2026) |
|
||||
| A11Y-01 | Labels, focus et modales | `DONE` | Parcours clavier et audit axe sans critique | labels inscription/création, `Modal.test.tsx`, axe dans `critical-flow.spec.ts` ; Vitest 4 et Playwright 1 verts (10 août 2026) |
|
||||
|
||||
## G2 — Activation produit
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| ACT-01 | CTA « Créer mon événement » | `DONE` | Intention claire sur homepage et vitrines | `Landing.tsx`, `ServiceVitrine.tsx` ; CTA commun vers le Hub (10 août 2026) |
|
||||
| ACT-02 | Conserver `next` dans l'auth | `DONE` | Retour vers `/hub/new`, jamais vers `/` par défaut | `authRedirect.ts`, auth password/code/magic-link, tests Vitest/Pytest et E2E (10 août 2026) |
|
||||
| ACT-03 | Onboarding Hub guidé | `DONE` | Hub + premier module + partage sans impasse | `HubNew.tsx`, `HubEdit.tsx` ; Playwright CTA→Hub→Kdo→copie : vert (10 août 2026) |
|
||||
| ACT-04 | Démo produit publique | `DONE` | Hub et parcours invité compréhensibles sans compte | `/demo` bilingue et SSG ; Playwright vues invité/organisateur ; build 32 pages (10 août 2026) |
|
||||
| ACT-05 | CTA invité post-action | `DONE` | Proposition après succès, sans interrompre l'action | `GuestConversionCTA.tsx`, intégré après succès Kdo/Kontrib/Kount/Kal/Kwiz ; test Vitest (10 août 2026) |
|
||||
|
||||
## G3 — Mesure first-party
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| ANA-01 | ADR analytics respectueux des données | `TODO` | Finalités, rétention et PII documentées | — |
|
||||
| ANA-02 | Cycle de vie du Hub | `TODO` | draft/active/completed/archived définis et migrés | — |
|
||||
| ANA-03 | Événements du funnel | `TODO` | 9 événements minimaux reçus et dédupliqués | — |
|
||||
| ANA-04 | Dashboard activation | `TODO` | Taux étape par étape et segmentation source/device | — |
|
||||
| ANA-05 | Métrique virale | `TODO` | invités/organisateur et conversion invité→organisateur | — |
|
||||
|
||||
## G4 — Refonte marketing
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| MKT-01 | Hero centré sur « un seul lien » | `TODO` | USP et absence d'installation visibles sans scroll | — |
|
||||
| MKT-02 | Scénario problème→solution | `TODO` | Modules secondaires par rapport au Hub | — |
|
||||
| MKT-03 | Screenshots/vidéo courte | `TODO` | Desktop + mobile + vue invité, accessibles et optimisés | — |
|
||||
| MKT-04 | Confiance factuelle | `TODO` | Hébergement, données et suivi précisément prouvés | — |
|
||||
| MKT-05 | Preuve sociale réelle | `TODO` | Aucun chiffre ou témoignage non vérifiable | — |
|
||||
|
||||
## G5 — Fondations SEO
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| SEO-01 | Corriger URL JSON-LD des articles | `TODO` | `mainEntityOfPage` égale l'URL canonique réelle | — |
|
||||
| SEO-02 | Mapper les slugs FR/EN | `TODO` | hreflang et sélecteur pointent vers la traduction réelle | — |
|
||||
| SEO-03 | Tests du pré-rendu | `TODO` | h1, canonical, lang, alternates, OG, JSON-LD validés | — |
|
||||
| SEO-04 | Métadonnées des vitrines | `TODO` | bénéfice/requête avant nom K | — |
|
||||
| PERF-01 | Séparer registre metadata/composants | `TODO` | bundle initial mesuré et réduit | — |
|
||||
|
||||
## G6 — Acquisition SEO
|
||||
|
||||
Créer un template de landing page bilingue, piloté par configuration et inscrit
|
||||
dans la source unique du pré-rendu/sitemap. Premier lot recommandé :
|
||||
|
||||
| ID | Page | Statut |
|
||||
|---|---|---|
|
||||
| SEO-LP-01 | `/organiser/anniversaire` | `TODO` |
|
||||
| SEO-LP-02 | `/organiser/week-end-entre-amis` | `TODO` |
|
||||
| SEO-LP-03 | `/organiser/repas` | `TODO` |
|
||||
| SEO-LP-04 | `/organiser/voyage` | `TODO` |
|
||||
| SEO-LP-05 | `/outils/sondage-date` | `TODO` |
|
||||
| SEO-LP-06 | `/outils/liste-cadeaux` | `TODO` |
|
||||
| SEO-LP-07 | `/outils/qui-apporte-quoi` | `TODO` |
|
||||
| SEO-LP-08 | `/outils/partage-depenses` | `TODO` |
|
||||
| SEO-LP-09 | Comparatif Doodle | `TODO` |
|
||||
| SEO-LP-10 | Comparatif Splitwise/Tricount | `TODO` |
|
||||
|
||||
Critères communs : intention distincte, contenu non dupliqué, démonstration produit,
|
||||
CTA contextualisé, maillage, FR/EN, SSG, sitemap, canonical et données structurées.
|
||||
|
||||
## G7 — Boucles de croissance
|
||||
|
||||
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|
|
||||
| GRW-01 | Conversion invité→organisateur | `TODO` | Mesurée avant optimisation | — |
|
||||
| GRW-02 | Templates par cas d'usage | `TODO` | Hub préconfiguré sans contenu artificiel | — |
|
||||
| GRW-03 | Dupliquer un événement | `TODO` | Nouveau Hub sans données personnelles invitées | — |
|
||||
| GRW-04 | Relance post-événement | `TODO` | Consentement, fréquence et désinscription conformes | — |
|
||||
| GRW-05 | Expériences A/B | `TODO` | Hypothèse, métrique, puissance et décision consignées | — |
|
||||
|
||||
## Indicateurs de pilotage
|
||||
|
||||
- Sécurité : zéro vulnérabilité critique connue ; temps de correction P0.
|
||||
- Fiabilité : taux de CI, erreurs 5xx, restauration vérifiée.
|
||||
- Activation : visite→CTA, inscription→Hub, Hub→module, module→partage,
|
||||
partage→premier invité.
|
||||
- Viralité : invités actifs par organisateur et taux invité→organisateur.
|
||||
- Rétention : deuxième événement créé à 30/90 jours.
|
||||
- SEO : pages valides/indexées, impressions non-brand, CTR et création de Hub par
|
||||
landing page.
|
||||
- Performance : LCP < 2,5 s, CLS < 0,1 et absence de régression du bundle initial.
|
||||
|
||||
## Journal des décisions et passations
|
||||
|
||||
### 2026-08-10 — création du suivi commun
|
||||
|
||||
- Décision : G0 sécurité devient un préalable à l'acquisition.
|
||||
- Décision : les documents versionnés dans `docs/` sont canoniques ; la mémoire
|
||||
`/home/miaw/.claude` ne contient qu'un index et du contexte court.
|
||||
- Audit initial réalisé par Codex ; aucune remédiation de code incluse.
|
||||
- Validation disponible : analyse syntaxique Python réussie ; build frontend non
|
||||
exécuté car `npm` absent.
|
||||
|
||||
### 2026-08-10 — G0 lancé (Codex)
|
||||
|
||||
- `P0-SEC-01` : `/verify-email` ne renvoie plus de JWT pour un compte déjà vérifié ; les codes erronés, expirés et consommés restent rejetés.
|
||||
- `P0-SEC-02` : la réinscription ne modifie plus un compte existant et vérifié, y compris passwordless ; la politique de mot de passe est mutualisée.
|
||||
- `P0-SEC-03` : le contrat public Kount utilise des schémas séparés sans email ni `cancel_token` ; le créateur conserve son jeton dans la réponse de création.
|
||||
- `P0-BILL-01` : la suppression annule d’abord les abonnements Stripe facturables, sinon retourne `502` sans effacer le compte ; politique ajoutée aux CGU et à la confidentialité.
|
||||
- Tests ajoutés dans `tests/` et dépendances dans `requirements-dev.txt` ; exécutés dans un conteneur éphémère API : `18 passed in 1.58s`.
|
||||
- `P0-INFRA-01` : image locale historique `kankwa-api:latest` confirmée porteuse de `.env`, `.git` et `frontend/node_modules` (sans afficher de secret). Les images de contrôle `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit`, construites avec les nouveaux `.dockerignore`, ne les contiennent pas. Rotation de secrets requise avant déploiement : `docs/runbooks/secret-rotation.md`.
|
||||
- G0 est `DONE` : les rotations externes Stripe, SMTP et TradeDoubler sont explicitement ignorées par décision du propriétaire.
|
||||
|
||||
### 2026-08-10 — validation et rotation locale G0 (Codex)
|
||||
|
||||
- Tests G0 exécutés en conteneur éphémère API, dépôt monté en lecture seule :
|
||||
`18 passed in 1.58s`.
|
||||
- Rotation locale appliquée sans divulgation : mot de passe PostgreSQL (et URL de
|
||||
connexion), clé JWT et jeton d’administration. L’API a été reconstruite avec le
|
||||
`.dockerignore` et son contrôle `GET /health` est vert. Les sessions JWT
|
||||
précédentes sont invalidées.
|
||||
- Restent à révoquer dans les tableaux de bord fournisseurs : Stripe (clé secrète
|
||||
et signature webhook), SMTP et TradeDoubler. Voir
|
||||
`docs/runbooks/secret-rotation.md`. G0 est `DONE` : les rotations externes sont explicitement ignorées par décision du propriétaire.
|
||||
|
||||
### 2026-08-10 — clôture technique G0 (Codex)
|
||||
|
||||
- Couverture complétée : magic-link, invitation, contrat HTTP public Kount,
|
||||
suppression Stripe réussie et webhook d’annulation.
|
||||
- Suite Python conteneurisée : `18 passed in 1.64s`.
|
||||
- Frontend reconstruit et redémarré ; contrôle HTTP vert. Les CGU et la
|
||||
confidentialité mises à jour sont donc publiées.
|
||||
- `P0-SEC-01`, `P0-SEC-02`, `P0-SEC-03` et `P0-BILL-01` sont `DONE`.
|
||||
- G0 est `DONE` : le propriétaire accepte explicitement le risque des rotations externes non effectuées.
|
||||
|
||||
### 2026-08-10 — acceptation du risque rotations externes
|
||||
|
||||
- Le propriétaire demande explicitement de ne pas révoquer Stripe, SMTP et
|
||||
TradeDoubler pour `P0-INFRA-01`. Le risque résiduel est accepté et documenté ;
|
||||
`P0-INFRA-01` et G0 passent à `DONE`.
|
||||
|
||||
### 2026-08-10 — QLT-01 terminé (Codex)
|
||||
|
||||
- Ajout de `docker-compose.test.yml` (PostgreSQL isolé en `tmpfs`) et de
|
||||
`docs/TESTING.md`.
|
||||
- Migrations Alembic complètes puis suite Pytest : `19 passed in 1.95s`.
|
||||
- Le test d’intégration couvre inscription réelle, droits owner/co-owner et la
|
||||
réponse publique Kount contre PostgreSQL.
|
||||
- G1 reste `IN_PROGRESS` ; prochain item : QLT-02 (Vitest/Testing Library).
|
||||
|
||||
### 2026-08-10 — QLT-02 terminé, QLT-04 lancé (Codex)
|
||||
|
||||
- Vitest + Testing Library ajoutés ; tests du cycle AuthProvider et de la matrice
|
||||
Kal : `3 passed`.
|
||||
- Workflow GitHub Actions `.github/workflows/quality.yml` ajouté : migrations et
|
||||
Pytest PostgreSQL, Vitest, i18n, TypeScript, build et pré-rendu frontend.
|
||||
|
||||
### 2026-08-10 — QLT-04 terminé (Codex)
|
||||
|
||||
- CI validée localement : PostgreSQL/migrations/Pytest, Vitest, i18n, TypeScript,
|
||||
build et pré-rendu frontend.
|
||||
- Prochain item : QLT-03, E2E Playwright inscription → partage → invité.
|
||||
|
||||
### 2026-08-10 — correctifs sécurité et exploitation G1 (Codex)
|
||||
|
||||
- `SEC-04` : headers de défense ajoutés dans l’API et le modèle Caddy ; CSP compatible Stripe ; décision de migration cookie HttpOnly documentée, sans prétendre que le JWT localStorage est déjà migré.
|
||||
- `SEC-05` : limitation par route logique + plafond global, stockage LRU borné à 10 000 buckets, `Retry-After` et en-tête IP seulement fiable via proxy local.
|
||||
- `SEC-06` : le scraping borne URL, HTML, redirections, délai et concurrence ; les URL finales FlareSolverr sont revalidées.
|
||||
- `DATA-02` est en cours : verrou transactionnel ajouté pour la limite freemium et les contributions/réservations, mais le test de concurrence PostgreSQL explicite reste à ajouter.
|
||||
- `OPS-02` : runbooks et scripts ajoutés ; dump/restauration validée sur PostgreSQL isolé au niveau Alembic `0026`, jamais sur la production.
|
||||
- `FE-01` : ErrorBoundary global avec rechargement récupérable. `A11Y-01` reste en cours : rôle, focus trap, Escape et restauration de focus sont centralisés dans `Modal`, mais l’audit axe de tous les parcours reste requis.
|
||||
- Validation après ces changements : 23 tests Pytest, Vitest, i18n et TypeScript verts dans les conteneurs de contrôle.
|
||||
|
||||
### 2026-08-10 — clôture G1 (Codex)
|
||||
|
||||
- `DATA-01` : contraintes Pydantic communes appliquées aux entrées des services ; les valeurs invalides sont rejetées avant SQL. Les réponses historiques restent tolérantes lorsque leur valeur peut légitimement être nulle ou zéro.
|
||||
- `DATA-02` : les créations facturées sont sérialisées par verrou de ligne utilisateur ; réservations et contributions verrouillent l’item. Le test PostgreSQL de deux créations concurrentes confirme qu’une seule passe la limite freemium.
|
||||
- `BILL-02` : l’état Premium effectif (`is_premium` et échéance) pilote désormais `/auth/me`, `/auth/me/usage`, les droits Premium et Checkout. Un abonnement expiré est traité comme gratuit partout et peut relancer Checkout.
|
||||
- `QLT-03` et `A11Y-01` : Playwright couvre inscription, création de liste, lien de partage et vue invité avec contrats HTTP simulés ; axe ne relève aucune violation critique sur cette vue. La modale rétablit le focus, piège Tab et gère Échap ; les champs du parcours ont des noms accessibles.
|
||||
- `OPS-01` : images de base figées par digest, `npm ci`, utilisateurs non-root, healthchecks et cibles Caddy alignées sur les noms/ports Compose. `docker compose config --quiet`, validation Caddy et builds d’images ont réussi.
|
||||
- Validation finale : PostgreSQL/Alembic/Pytest isolés `36 passed in 2.58s` ; Vitest `4 passed` ; Playwright Chromium `1 passed` ; build frontend avec i18n et pré-rendu de 30 pages réussi. G1 est `DONE`.
|
||||
|
||||
### 2026-08-10 — clôture G2 (Codex)
|
||||
|
||||
- Les CTA homepage et vitrines expriment désormais l’intention « Créer mon événement » et conservent `/hub/new` à travers inscription, connexion, vérification email et magic link ; les destinations externes sont rejetées.
|
||||
- Après création, le Hub affiche une checklist guidée : Hub créé, premier module, puis copie ou prévisualisation du lien invité.
|
||||
- `/demo` fournit une démonstration bilingue sans compte des vues invité et organisateur ; la route est inscrite dans la source SSG/sitemap.
|
||||
- Le CTA de conversion invité est partagé et ne s’affiche qu’après une action réussie dans Kdo, Kontrib, Kount, Kal ou Kwiz ; il reste absent pour un utilisateur connecté.
|
||||
- Validation : PostgreSQL/Alembic/Pytest isolés `37 passed` ; i18n et TypeScript verts ; Vitest `8 passed` ; Playwright Chromium `3 passed` ; image `kankwa-frontend:g2` construite avec 32 pages pré-rendues (16 FR + 16 EN). G2 est `DONE`.
|
||||
44
docs/TESTING.md
Normal file
44
docs/TESTING.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Tests backend
|
||||
|
||||
Le backend se valide dans un environnement Docker isolé, jamais contre la base de
|
||||
production. La commande construit l’image de test, démarre PostgreSQL en mémoire
|
||||
(`tmpfs`), applique toutes les migrations Alembic, exécute Pytest puis arrête les
|
||||
conteneurs.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Le projet Compose s’appelle `kankwa-test` et ne partage aucun conteneur ni réseau
|
||||
avec la production. Après une interruption manuelle, nettoyer uniquement son
|
||||
environnement :
|
||||
|
||||
```bash
|
||||
docker compose -p kankwa-test -f docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
La suite inclut des tests unitaires et une intégration PostgreSQL couvrant
|
||||
l’inscription, les droits owner/co-owner et une route publique Kount.
|
||||
|
||||
## Tests frontend
|
||||
|
||||
Vitest et Testing Library sont exécutés avec Node dans Docker :
|
||||
|
||||
```bash
|
||||
docker run --rm --mount type=bind,src="$PWD",target=/workspace --workdir /workspace node:20-alpine sh -c "npm ci && npm run test"
|
||||
```
|
||||
|
||||
## Tests E2E navigateur
|
||||
|
||||
Le scénario critique `inscription → création → partage → invité` est exécuté dans
|
||||
l’image officielle Playwright. Les réponses HTTP des services externes sont
|
||||
simulées au niveau du navigateur : le test ne contacte ni la production, ni SMTP,
|
||||
ni Stripe. Il vérifie également l’absence de violation axe à impact critique sur la
|
||||
vue invitée.
|
||||
|
||||
```bash
|
||||
docker run --rm --network host \
|
||||
--mount type=bind,src="$PWD/frontend",target=/workspace \
|
||||
--workdir /workspace mcr.microsoft.com/playwright:v1.62.1-noble \
|
||||
sh -c "npm ci && npm run test:e2e"
|
||||
```
|
||||
206
docs/audits/2026-08-code-produit.md
Normal file
206
docs/audits/2026-08-code-produit.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Audit Kankwa — code, produit et acquisition
|
||||
|
||||
Date de référence : 10 août 2026
|
||||
État : audit initial documenté, correctifs non présumés
|
||||
Source marketing : audit du front public fourni par le propriétaire du projet
|
||||
|
||||
Ce fichier conserve le diagnostic au moment de l'audit. L'avancement et les
|
||||
changements ultérieurs sont suivis exclusivement dans `docs/ROADMAP.md`.
|
||||
|
||||
## Périmètre et méthode
|
||||
|
||||
L'analyse a porté sur le monorepo FastAPI/React, les modèles et migrations, les
|
||||
routes publiques et privées, l'authentification, Stripe, le scraping, Docker,
|
||||
Caddy, le pré-rendu SEO, l'i18n, les pages marketing et le funnel d'activation.
|
||||
|
||||
Vérifications réalisées :
|
||||
|
||||
- lecture des sources et des 26 migrations ;
|
||||
- analyse syntaxique réussie de 110 fichiers Python ;
|
||||
- rapprochement du code avec l'audit marketing fourni ;
|
||||
- inventaire des tests, de la CI et de l'instrumentation.
|
||||
|
||||
Limites : le build frontend n'a pas pu être exécuté, car `npm` n'était pas
|
||||
disponible dans l'environnement d'audit. Aucun environnement PostgreSQL/Stripe de
|
||||
test n'a été démarré. Les dépendances n'ont pas fait l'objet d'un scan CVE réseau.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le diagnostic marketing est confirmé : Kankwa possède un produit cohérent et un
|
||||
Hub différenciant, mais montre encore davantage ses modules que l'expérience
|
||||
« créer, partager, participer ». Le dépôt dispose déjà d'un socle SEO plus avancé
|
||||
que le front visible ne le laisse penser, avec SSG bilingue, sitemap et métadonnées.
|
||||
|
||||
La roadmap marketing ne doit toutefois pas commencer immédiatement. Plusieurs
|
||||
vulnérabilités critiques permettent une prise de contrôle de comptes, exposent des
|
||||
jetons d'annulation ou risquent d'inclure des secrets dans les images Docker. Le
|
||||
jalon de sécurité G0 est donc un préalable à l'acquisition.
|
||||
|
||||
## Évaluation synthétique
|
||||
|
||||
| Domaine | Note | Diagnostic |
|
||||
|---|---:|---|
|
||||
| Architecture fonctionnelle | 7,5/10 | Bonne séparation services/shared |
|
||||
| Produit et Hub | 8/10 | Produit riche, Hub réellement central |
|
||||
| Sécurité | 3/10 | Vulnérabilités critiques à traiter avant acquisition |
|
||||
| Tests et CI | 1,5/10 | Aucun test ou pipeline détecté |
|
||||
| SEO technique | 6,5/10 | Bon SSG, défauts canonical/hreflang |
|
||||
| SEO éditorial | 4/10 | Quatre articles, peu de pages par intention |
|
||||
| Conversion | 5/10 | Promesse présente, démonstration et activation faibles |
|
||||
| Analytics/growth | 2/10 | Stats partielles, aucun funnel instrumenté |
|
||||
| RGPD/transparence | 4/10 | Politique incomplète par rapport aux traitements réels |
|
||||
| Exploitation | 4/10 | Images non durcies et proxy divergent |
|
||||
|
||||
## Constats critiques
|
||||
|
||||
### SEC-01 — émission d'un JWT sans validation du code
|
||||
|
||||
`POST /api/auth/verify-email` renvoie un token dès que le compte est déjà vérifié,
|
||||
avant l'appel à `verify_code`. Une adresse connue et un code arbitraire peuvent
|
||||
donc suffire à ouvrir une session sur ce compte.
|
||||
|
||||
Preuve : `api/routers/auth.py`, route `verify_email`, lignes 189–202 lors de l'audit.
|
||||
|
||||
### SEC-02 — définition non autorisée du mot de passe d'un compte passwordless
|
||||
|
||||
La route d'inscription remplace le mot de passe d'un compte existant dès que
|
||||
`password_hash` est vide, y compris si le compte a déjà été vérifié par magic link
|
||||
ou invitation.
|
||||
|
||||
Preuve : `api/routers/auth.py`, route `register`, lignes 118–134 lors de l'audit.
|
||||
|
||||
### SEC-03 — jetons d'annulation et emails exposés par Kount
|
||||
|
||||
La réponse publique Kount réutilise `MemberOut` et `ExpenseOut`. Ces schémas
|
||||
contiennent respectivement l'email et `cancel_token`. Le token permet ensuite de
|
||||
supprimer la dépense sans authentification.
|
||||
|
||||
Preuves :
|
||||
|
||||
- `services/kount/schemas.py`, `MemberOut`, `ExpenseOut`, `KountGroupPublicResponse` ;
|
||||
- `services/kount/service.py`, `_expense_out` et `_member_out` ;
|
||||
- `services/kount/router.py`, `get_public_group` et `cancel_expense`.
|
||||
|
||||
### INFRA-01 — `.env` potentiellement copié dans l'image backend
|
||||
|
||||
Le Dockerfile exécute `COPY . .` et aucun `.dockerignore` n'existe. Le contexte
|
||||
inclut donc potentiellement `.env`, `.git`, les caches et `frontend/node_modules`.
|
||||
|
||||
Preuve : `Dockerfile`, ligne `COPY . .`, et absence de `.dockerignore` à la date de
|
||||
l'audit.
|
||||
|
||||
### BILL-01 — suppression de compte sans arrêt de l'abonnement
|
||||
|
||||
La route de suppression efface seulement l'utilisateur PostgreSQL. Aucun arrêt ou
|
||||
contrôle de l'abonnement Stripe n'est effectué, ce qui peut laisser une facturation
|
||||
active sans compte applicatif.
|
||||
|
||||
Preuve : `api/routers/auth.py`, route `delete_account`.
|
||||
|
||||
## Sécurité et résilience
|
||||
|
||||
- JWT de sept jours stocké dans `localStorage`, aggravé par l'absence de CSP et de
|
||||
headers de durcissement dans le Caddyfile exemple.
|
||||
- Rate limiting en mémoire, par IP et chemin exact : contournable via les routes
|
||||
dynamiques, non partagé entre instances et susceptible de conserver une infinité
|
||||
de clés.
|
||||
- FlareSolverr suit les redirections hors du contrôle effectué par `_validate_url` ;
|
||||
le fallback HTTPX revalide chaque saut mais pas le chemin principal.
|
||||
- `/parse-html` ne limite pas explicitement la taille du HTML à parser.
|
||||
- Plusieurs règles métier utilisent `count puis insert` sans verrou : limite
|
||||
freemium, réservation/contribution et plafonds de collecte.
|
||||
- Les identifiants polymorphes `resource_id` et plusieurs `event_id` ne bénéficient
|
||||
pas de clés étrangères ; leur nettoyage dépend du code applicatif.
|
||||
- Les contraintes de longueur Pydantic sont incomplètes par rapport aux colonnes
|
||||
SQL et peuvent transformer une entrée invalide en erreur 500.
|
||||
- Le statut Premium effectif est calculé par `is_premium_active`, mais plusieurs
|
||||
routes et réponses utilisent directement le booléen potentiellement périmé.
|
||||
|
||||
## Produit et activation
|
||||
|
||||
La homepage contient déjà le problème, la solution, le Hub et la participation
|
||||
sans installation. L'audit externe est donc partiellement dépassé sur le wording.
|
||||
Les écarts principaux restent :
|
||||
|
||||
- aucune capture, vidéo courte ou démo du Hub ;
|
||||
- grille de modules encore dominante ;
|
||||
- CTA générique « Commencer gratuitement » ;
|
||||
- inscription et connexion renvoient vers `/` au lieu du démarrage du Hub ;
|
||||
- aucun onboarding guidé jusqu'au premier module et au partage ;
|
||||
- aucune preuve sociale vérifiable ;
|
||||
- aucun parcours post-participation pour convertir un invité en organisateur.
|
||||
|
||||
Positionnement retenu pour la suite :
|
||||
|
||||
> Un seul lien pour organiser votre moment entre proches. L'organisateur centralise
|
||||
> tout ; les invités participent sans installer d'application ni créer de compte.
|
||||
|
||||
Les noms K restent la marque et la navigation produit. L'acquisition doit présenter
|
||||
le bénéfice avant le nom du module.
|
||||
|
||||
## SEO
|
||||
|
||||
### Acquis
|
||||
|
||||
- pré-rendu SSG FR/EN avec Puppeteer ;
|
||||
- sources communes `seo-routes.json` et `articles.json` ;
|
||||
- sitemap bilingue avec alternates ;
|
||||
- métadonnées OG/Twitter et JSON-LD ;
|
||||
- `noindex` React et `X-Robots-Tag` pour les pages partagées ;
|
||||
- quatre articles ciblant des intentions utiles.
|
||||
|
||||
### Défauts
|
||||
|
||||
- les articles sont routés à la racine mais le JSON-LD construit `/blog/{slug}` ;
|
||||
- les alternates HTML réutilisent le même chemin alors que les slugs d'articles
|
||||
diffèrent entre FR et EN ;
|
||||
- le changement de langue d'un article conserve le slug et peut aboutir à une 404 ;
|
||||
- les vitrines utilisent des titres trop génériques ;
|
||||
- absence de pages structurées `/organiser`, `/outils` et `/alternatives` ;
|
||||
- absence de tests automatiques sur le HTML pré-rendu, canonical, hreflang et JSON-LD.
|
||||
|
||||
## Données, analytics et croissance
|
||||
|
||||
Le dépôt ne contient pas d'instrumentation du funnel. Les statistiques admin
|
||||
mesurent surtout les créations et une conversion invité limitée à certaines
|
||||
actions Kdo.
|
||||
|
||||
Le modèle Hub ne possède pas de cycle de vie explicite permettant de distinguer
|
||||
`draft`, `active`, `completed` et `archived`. Il est donc impossible de mesurer
|
||||
proprement `event_completed` ou de déclencher une relance post-événement fiable.
|
||||
|
||||
Les événements minimaux recommandés sont : `landing_view`, `cta_click`,
|
||||
`signup_completed`, `hub_created`, `module_added`, `share_link_copied`,
|
||||
`guest_opened`, `guest_participated` et `second_event_created`.
|
||||
|
||||
## RGPD et transparence
|
||||
|
||||
La politique indique que les données ne sont jamais partagées avec des tiers alors
|
||||
que le produit utilise Stripe, un prestataire SMTP et des plateformes d'affiliation.
|
||||
Elle ne détaille pas suffisamment les données invitées, bases légales, durées de
|
||||
conservation, sous-traitants, sauvegardes ou données de paiement.
|
||||
|
||||
La bannière parle de cookies essentiels alors que la session est actuellement
|
||||
conservée dans `localStorage`. Tant qu'aucun traceur non essentiel n'est utilisé,
|
||||
une information claire est préférable à une demande de consentement trompeuse.
|
||||
|
||||
## Qualité, accessibilité et exploitation
|
||||
|
||||
- aucun test backend, frontend ou end-to-end détecté ;
|
||||
- aucune CI détectée ;
|
||||
- aucun `ErrorBoundary` global ;
|
||||
- nombreux formulaires utilisant des placeholders sans labels persistants ;
|
||||
- registre des services mêlant métadonnées et composants importés, ce qui élargit
|
||||
probablement le bundle initial ;
|
||||
- `npm install` au lieu de `npm ci` dans l'image frontend ;
|
||||
- image FlareSolverr référencée par le tag mutable `latest` ;
|
||||
- conteneurs sans utilisateur non-root explicite ;
|
||||
- `Caddyfile.example` incohérent avec les ports et noms de conteneurs documentés ;
|
||||
- aucune procédure versionnée de sauvegarde et restauration PostgreSQL.
|
||||
|
||||
## Décision de séquencement
|
||||
|
||||
L'ordre obligatoire issu de l'audit est : sécurité critique, socle de tests,
|
||||
activation, instrumentation, refonte marketing, SEO à l'échelle, puis growth. Les
|
||||
nouvelles fonctionnalités produit ne sont pas prioritaires tant que les jalons G0
|
||||
et G1 ne sont pas validés.
|
||||
24
docs/decisions/2026-08-session-http-only.md
Normal file
24
docs/decisions/2026-08-session-http-only.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Décision — migration des sessions vers des cookies HttpOnly
|
||||
|
||||
Date : 10 août 2026 — statut : adoptée, migration planifiée.
|
||||
|
||||
Le jeton JWT actuel est transmis dans `Authorization` et conservé par le frontend.
|
||||
Les headers CSP, anti-frame, `nosniff`, politique de référent et `no-store` pour les
|
||||
réponses d’authentification réduisent l’exposition, mais ne transforment pas ce
|
||||
stockage en protection contre une XSS. Il ne doit donc pas être considéré comme la
|
||||
cible finale.
|
||||
|
||||
La cible est un cookie `Secure; HttpOnly; SameSite=Lax; Path=/api`, émis par les
|
||||
routes d’authentification. Les écritures devront protéger contre CSRF par une
|
||||
vérification `Origin` stricte et un jeton CSRF non HttpOnly à double soumission.
|
||||
La suppression de session effacera le cookie avec les mêmes attributs. Les liens
|
||||
magic et vérification ne placent jamais de jeton dans une URL de redirection.
|
||||
|
||||
Migration compatible : accepter temporairement Bearer **ou** cookie côté API,
|
||||
puis publier le frontend cookie-only, observer les 401 et enfin retirer Bearer et
|
||||
`localStorage.token`. Aucun cookie ne sera ajouté avant la validation des domaines
|
||||
FR/EN et des flux Stripe embarqués en préproduction.
|
||||
|
||||
Les tests G1 vérifient les headers sur l’API ; la migration cookie ajoute comme
|
||||
critères de sortie les attributs du `Set-Cookie`, le refus CSRF cross-origin et
|
||||
l’absence de JWT dans `localStorage`.
|
||||
20
docs/runbooks/postgres-backup-restore.md
Normal file
20
docs/runbooks/postgres-backup-restore.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Sauvegarde et restauration PostgreSQL
|
||||
|
||||
La sauvegarde est chiffrée/stockée hors de l’hôte par l’exploitation ; aucun secret
|
||||
n’est inscrit ici. Lancer la sauvegarde avec un chemin explicite, permissions 0600 :
|
||||
|
||||
```bash
|
||||
scripts/backup-postgres.sh /chemin/sur-stockage-protégé/kankwa-AAAA-MM-JJ.sql.gz
|
||||
```
|
||||
|
||||
Ne jamais restaurer une sauvegarde dans le conteneur de production sans fenêtre de
|
||||
maintenance et validation du fichier. La commande demande le nom du fichier et
|
||||
utilise le conteneur `db` défini par Compose :
|
||||
|
||||
```bash
|
||||
scripts/restore-postgres.sh /chemin/validé/kankwa-AAAA-MM-JJ.sql.gz
|
||||
```
|
||||
|
||||
Après restauration : vérifier `/health`, le niveau Alembic et un parcours de lecture
|
||||
non sensible. Une restauration de contrôle doit être exécutée au moins chaque mois
|
||||
sur une base isolée ; la date et le résultat sont consignés dans `docs/ROADMAP.md`.
|
||||
62
docs/runbooks/secret-rotation.md
Normal file
62
docs/runbooks/secret-rotation.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Rotation des secrets après exposition Docker
|
||||
|
||||
Date : 10 août 2026
|
||||
Statut : action opérateur requise avant tout nouveau déploiement de l’API.
|
||||
|
||||
## Constat vérifié
|
||||
|
||||
L’image locale historique `kankwa-api:latest` contient `/app/.env`, `/app/.git` et
|
||||
`/app/frontend/node_modules`. Aucun contenu de secret n’a été affiché pendant
|
||||
l’inspection. L’origine et la diffusion passée de cette image ne sont pas connues
|
||||
depuis ce poste.
|
||||
|
||||
Les images de contrôle `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit`, construites le 10 août 2026 avec les nouveaux `.dockerignore`, ne contiennent aucun de ces chemins.
|
||||
|
||||
## Décision
|
||||
|
||||
Toute image API construite avant le 10 août 2026 doit être considérée comme
|
||||
potentiellement porteuse de secrets. Elle ne doit plus être poussée, distribuée ni
|
||||
déployée. Avant le prochain déploiement de l’API, l’opérateur doit faire tourner
|
||||
les secrets de production qui auraient pu figurer dans `.env`, notamment les accès
|
||||
base de données, clé JWT, SMTP, Stripe, fournisseurs d’affiliation et jetons
|
||||
d’administration. Les valeurs ne doivent jamais être consignées dans ce dépôt.
|
||||
|
||||
## Rotation locale effectuée
|
||||
|
||||
Le 10 août 2026, les valeurs locales de `POSTGRES_PASSWORD` (et la composante
|
||||
mot de passe de `DATABASE_URL`), `SECRET_KEY` et `ADMIN_TOKEN` ont été
|
||||
renouvelées sans journaliser leur contenu. Le mot de passe PostgreSQL a été
|
||||
modifié dans la base, puis l’API a été reconstruite depuis le contexte assaini et
|
||||
redémarrée avec succès (`GET /health`). Les JWT existants ont été invalidés par
|
||||
le renouvellement de `SECRET_KEY`.
|
||||
|
||||
## Rotations fournisseur encore requises
|
||||
|
||||
Les valeurs suivantes ne peuvent pas être révoquées depuis cet hôte :
|
||||
|
||||
- `STRIPE_SECRET_KEY` et `STRIPE_WEBHOOK_SECRET` : créer/révoquer dans le
|
||||
tableau de bord Stripe, puis mettre à jour l’environnement de production ;
|
||||
- `SMTP_PASSWORD` : renouveler le mot de passe ou le jeton SMTP chez le
|
||||
fournisseur mail ;
|
||||
- `TRADEDOUBLER_CLIENT_SECRET` : révoquer et recréer dans le tableau de bord
|
||||
TradeDoubler.
|
||||
|
||||
Après chaque opération fournisseur, mettre à jour `.env` hors dépôt, redémarrer
|
||||
l’API et vérifier le flux concerné sans afficher la valeur.
|
||||
|
||||
## Procédure de clôture
|
||||
|
||||
1. Vérifier dans le registre, les sauvegardes et les hôtes de déploiement si une
|
||||
image API antérieure a été poussée ou partagée.
|
||||
2. Révoquer/renouveler les secrets concernés dans leurs fournisseurs respectifs et
|
||||
mettre à jour le gestionnaire de secrets de production.
|
||||
3. Construire et déployer une image neuve depuis cette révision, jamais depuis
|
||||
`kankwa-api:latest` historique.
|
||||
4. Vérifier sans afficher de valeur que les variables renouvelées sont chargées et
|
||||
que Stripe, email et base de données fonctionnent.
|
||||
5. Inscrire la date, le responsable et la référence de déploiement dans la preuve
|
||||
`P0-INFRA-01` de `docs/ROADMAP.md`.
|
||||
|
||||
## Acceptation explicite du risque
|
||||
|
||||
Le propriétaire a explicitement choisi d’ignorer les rotations Stripe, SMTP et TradeDoubler le 10 août 2026. Les rotations locales restent actives ; le risque résiduel fournisseur est accepté et `P0-INFRA-01` est `DONE`.
|
||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
|
|
@ -1,17 +1,20 @@
|
|||
FROM node:20-alpine AS builder
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder
|
||||
# Chromium sert au pré-rendu des pages publiques (scripts/prerender.mjs) — build only.
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY --from=builder /app/dist /srv
|
||||
COPY --from=builder /app/scripts/static-server.mjs /app/static-server.mjs
|
||||
COPY --from=builder /app/locales.json /app/locales.json
|
||||
# Serveur maison : fichier > <route>/index.html > shell SPA. `serve` appliquait ses
|
||||
# rewrites avant le lookup fichier et court-circuitait les pages pré-rendues.
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=builder --chown=app:app /app/dist /srv
|
||||
COPY --from=builder --chown=app:app /app/scripts/static-server.mjs /app/static-server.mjs
|
||||
COPY --from=builder --chown=app:app /app/locales.json /app/locales.json
|
||||
USER app
|
||||
EXPOSE 3001
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3001/').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
|
||||
CMD ["node", "/app/static-server.mjs", "/srv", "3001"]
|
||||
|
|
|
|||
131
frontend/e2e/critical-flow.spec.ts
Normal file
131
frontend/e2e/critical-flow.spec.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
const listId = '00000000-0000-4000-8000-000000000001'
|
||||
const userId = '00000000-0000-4000-8000-000000000002'
|
||||
const shareToken = '00000000-0000-4000-8000-000000000003'
|
||||
const createdAt = '2026-08-10T12:00:00Z'
|
||||
const eventId = '00000000-0000-4000-8000-000000000005'
|
||||
const eventShareToken = '00000000-0000-4000-8000-000000000006'
|
||||
const attachedServiceId = '00000000-0000-4000-8000-000000000007'
|
||||
|
||||
const item = {
|
||||
id: '00000000-0000-4000-8000-000000000004', name: 'Livre de voyage', description: null,
|
||||
price: null, url: null, image_url: null, position: 0, is_priority: false,
|
||||
is_reserved: false, reserved_by: null, participation_mode: false,
|
||||
contribution_total: 0, contribution_count: 0,
|
||||
}
|
||||
|
||||
const ownerList = {
|
||||
id: listId, user_id: userId, title: 'Voyage de Léa', description: null,
|
||||
share_token: shareToken, created_at: createdAt, items: [item], reservation_count: 0, event_id: null,
|
||||
}
|
||||
|
||||
const guestList = { id: listId, title: ownerList.title, description: null, items: [item] }
|
||||
|
||||
async function mockApi(page: import('@playwright/test').Page) {
|
||||
await page.route('**/api/auth/register', route => route.fulfill({ status: 201, json: { message: 'Code envoyé' } }))
|
||||
await page.route('**/api/auth/me', route => route.fulfill({ json: { id: userId, email: 'lea@example.test', lang: 'fr', email_verified: true, is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null, cancel_at_period_end: false, created_at: createdAt, has_password: true } }))
|
||||
await page.route('**/api/auth/verify-email', route => route.fulfill({
|
||||
json: {
|
||||
access_token: 'e2e-token', token_type: 'bearer',
|
||||
user: { id: userId, email: 'lea@example.test', lang: 'fr', email_verified: true, is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null, cancel_at_period_end: false, created_at: createdAt, has_password: true },
|
||||
},
|
||||
}))
|
||||
await page.route('**/api/kdo/lists', async route => {
|
||||
if (route.request().method() === 'POST') return route.fulfill({ status: 201, json: ownerList })
|
||||
return route.fulfill({ json: [] })
|
||||
})
|
||||
await page.route(`**/api/kdo/lists/${listId}`, route => route.fulfill({ json: ownerList }))
|
||||
await page.route(`**/api/kdo/lists/shared/${shareToken}`, route => route.fulfill({ json: guestList }))
|
||||
await page.route('**/api/comments/**', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/shared/ownership/**', route => route.fulfill({ json: [] }))
|
||||
}
|
||||
|
||||
test('inscription, création puis partage d’une liste à un invité', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
|
||||
await page.goto('/register?next=%2Fkdo%2Flists%2Fnew')
|
||||
await page.getByLabel('Email').fill('lea@example.test')
|
||||
await page.getByLabel('Mot de passe').fill('Motdepasse1!')
|
||||
await page.getByRole('button', { name: 'Créer mon compte' }).click()
|
||||
await page.getByPlaceholder('000000').fill('123456')
|
||||
await page.getByRole('button', { name: 'Vérifier' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/kdo\/lists\/new$/)
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('token'))).toBe('e2e-token')
|
||||
await page.getByLabel('Désignation *').fill('Voyage de Léa')
|
||||
await page.getByRole('button', { name: 'Créer la liste' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Voyage de Léa' })).toBeVisible()
|
||||
await expect(page.locator(`input[value$="/share/${shareToken}"]`)).toBeVisible()
|
||||
|
||||
await page.goto(`/share/${shareToken}`)
|
||||
await expect(page.getByRole('heading', { name: 'Voyage de Léa' })).toBeVisible()
|
||||
await expect(page.getByText('Livre de voyage')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Réserver' })).toBeVisible()
|
||||
const axe = await new AxeBuilder({ page }).analyze()
|
||||
expect(axe.violations.filter(v => v.impact === 'critical')).toEqual([])
|
||||
})
|
||||
|
||||
test('CTA, inscription, Hub, premier module puis partage', async ({ page, context }) => {
|
||||
let attached = false
|
||||
const event = () => ({
|
||||
id: eventId, user_id: userId, title: 'Anniversaire de Léa', event_type: 'birthday',
|
||||
event_date: null, location: null, description: null, share_token: eventShareToken,
|
||||
created_at: createdAt, notifications: [],
|
||||
services: attached ? [{ id: attachedServiceId, service_type: 'kdo', resource_id: listId, is_visible: true, created_at: createdAt }] : [],
|
||||
})
|
||||
|
||||
await mockApi(page)
|
||||
await page.route('**/api/hub/**', async route => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'POST' && request.url().endsWith(`/hub/${eventId}/services`)) {
|
||||
attached = true
|
||||
return route.fulfill({ status: 201, json: event().services[0] })
|
||||
}
|
||||
if (request.method() === 'GET' && request.url().endsWith(`/hub/${eventId}`)) {
|
||||
return route.fulfill({ json: event() })
|
||||
}
|
||||
return route.fulfill({ json: [] })
|
||||
})
|
||||
await page.route('**/api/hub', async route => {
|
||||
if (route.request().method() === 'POST') return route.fulfill({ status: 201, json: event() })
|
||||
return route.fulfill({ json: [] })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('link', { name: 'Créer mon événement' }).click()
|
||||
await expect(page).toHaveURL(/\/register\?next=%2Fhub%2Fnew/)
|
||||
await page.getByLabel('Email').fill('lea@example.test')
|
||||
await page.getByLabel('Mot de passe').fill('Motdepasse1!')
|
||||
await page.getByRole('button', { name: 'Créer mon compte' }).click()
|
||||
await page.getByPlaceholder('000000').fill('123456')
|
||||
await page.getByRole('button', { name: 'Vérifier' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/hub\/new$/)
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('token'))).toBe('e2e-token')
|
||||
await page.getByLabel('Nom du projet *').fill('Anniversaire de Léa')
|
||||
await page.getByRole('button', { name: 'Créer le projet' }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/hub/${eventId}\\?onboarding=1$`))
|
||||
await expect(page.getByRole('heading', { name: 'Votre événement est créé' })).toBeVisible()
|
||||
|
||||
const onboarding = page.getByRole('region', { name: 'Votre événement est créé' })
|
||||
await onboarding.getByRole('button', { name: /Kdo/ }).click()
|
||||
await page.getByPlaceholder('Désignation *').fill('Idées cadeaux')
|
||||
await page.getByRole('button', { name: 'Créer la liste' }).click()
|
||||
|
||||
const expectedShareUrl = `http://127.0.0.1:4173/hub/p/${eventShareToken}`
|
||||
await expect(page.getByRole('button', { name: 'Copier le lien' })).toBeVisible()
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.getByRole('button', { name: 'Copier le lien' }).click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expectedShareUrl)
|
||||
})
|
||||
|
||||
test('la démo publique explique les vues invité et organisateur sans compte', async ({ page }) => {
|
||||
await page.goto('/demo')
|
||||
await expect(page.getByRole('heading', { name: 'Un événement, un seul lien' })).toBeVisible()
|
||||
await expect(page.getByText('Aucun compte à créer : ouvrez l’outil utile et participez.')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Vue organisateur' }).click()
|
||||
await expect(page.getByText('Ajoutez, gérez et partagez tous les outils depuis cette page.')).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Créer mon événement' })).toHaveAttribute('href', '/register?next=%2Fhub%2Fnew')
|
||||
})
|
||||
1247
frontend/package-lock.json
generated
1247
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,10 @@
|
|||
"check:i18n": "node scripts/check-i18n.mjs",
|
||||
"build": "npm run check:i18n && tsc && vite build && npm run prerender",
|
||||
"prerender": "node scripts/prerender.mjs",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
|
|
@ -22,14 +25,21 @@
|
|||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.3",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
frontend/playwright.config.ts
Normal file
18
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:4173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: process.env.E2E_BASE_URL ? undefined : {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Route, Routes, useLocation, Navigate } from 'react-router-dom'
|
||||
import { AuthGuard, CookieBanner, Footer, Navbar, Spinner } from '@shared/components'
|
||||
import { AuthGuard, CookieBanner, ErrorBoundary, Footer, Navbar, Spinner } from '@shared/components'
|
||||
import { AuthProvider, useAuth } from '@shared/hooks/useAuth'
|
||||
import { services } from '@shared/config/services'
|
||||
import articles from '@shared/config/articles.json'
|
||||
|
|
@ -25,6 +25,7 @@ const Privacy = lazy(() => import('@shared/pages/Privacy'))
|
|||
const Terms = lazy(() => import('@shared/pages/Terms'))
|
||||
const NotFound = lazy(() => import('@shared/pages/NotFound'))
|
||||
const Account = lazy(() => import('@shared/pages/Account'))
|
||||
const Demo = lazy(() => import('@shared/pages/Demo'))
|
||||
const ArticleWeekend = lazy(() => import('@shared/pages/blog/ArticleWeekend'))
|
||||
const ArticleDoodle = lazy(() => import('@shared/pages/blog/ArticleDoodle'))
|
||||
const ArticleCadeauFrais = lazy(() => import('@shared/pages/blog/ArticleCadeauFrais'))
|
||||
|
|
@ -36,7 +37,7 @@ const BLOG_SLUGS = articles.flatMap((a: any) => [a.slug.fr, a.slug.en])
|
|||
|
||||
const PUBLIC_PREFIXES = ['/', '/magic', '/login', '/register',
|
||||
'/share', '/cancel', '/event', '/join', '/set-password', '/legal', '/privacy', '/terms',
|
||||
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/blog',
|
||||
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/blog', '/demo',
|
||||
'/alternative-doodle-gratuite-sans-pub', '/alternative-doodle-gratuite-sans-compte']
|
||||
|
||||
const PageSpinner = () => (
|
||||
|
|
@ -55,7 +56,8 @@ function AppRoutes() {
|
|||
|| BLOG_SLUGS.some((slug: string) => location.pathname === `/${slug}`)
|
||||
|
||||
if (!isLoading && user && !user.email_verified && !isPublic) {
|
||||
return <Navigate to="/verify-email" replace />
|
||||
const next = `${location.pathname}${location.search}${location.hash}`
|
||||
return <Navigate to={`/verify-email?next=${encodeURIComponent(next)}`} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -80,6 +82,7 @@ function AppRoutes() {
|
|||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/demo" element={<Demo />} />
|
||||
{/* Weekend article — FR + EN slugs */}
|
||||
<Route path="/organiser-week-end-entre-amis" element={<ArticleWeekend />} />
|
||||
<Route path="/plan-a-weekend-with-friends" element={<ArticleWeekend />} />
|
||||
|
|
@ -120,15 +123,17 @@ function AppRoutes() {
|
|||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<AppRoutes />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<AppRoutes />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
"terms": "Terms"
|
||||
},
|
||||
"vitrine": {
|
||||
"create_event": "Create my event",
|
||||
"view_demo": "View the demo",
|
||||
"start_free": "Get started for free",
|
||||
"login": "Sign in",
|
||||
"register_free": "Create a free account",
|
||||
|
|
@ -204,9 +206,10 @@
|
|||
"subheading": "Gift wishlists, availability polls, expense splitting, surveys, who brings what — all in one place. Your guests join without installing anything and no account required.",
|
||||
"meta_title": "Organize your moments with friends",
|
||||
"meta_desc": "Gift wishlists, polls, availability, expense sharing — all in one place. Your guests join without creating an account. No ads.",
|
||||
"cta_logged": "Go to my space →",
|
||||
"cta_register": "Get started for free",
|
||||
"cta_logged": "Create my event",
|
||||
"cta_register": "Create my event",
|
||||
"cta_login": "Sign in",
|
||||
"cta_demo": "See how it works →",
|
||||
"note": "No ads · Your data is yours",
|
||||
"problem_q": "How many apps to organize a get-together?",
|
||||
"problem_body": "One to find the date, one for expenses, a wishlist somewhere, and a chat group going in all directions. Kankwa brings everything together.",
|
||||
|
|
@ -232,5 +235,33 @@
|
|||
"why2_body": "Your lists, expenses, and availability are yours. We have no interest in reselling them — and we designed it that way from the start. No advertising, ever.",
|
||||
"why3_title": "Your guests join without creating an account.",
|
||||
"why3_body": "Share a link. That's it. No need to install anything. If you're logged in, find all your contributions in a blink."
|
||||
},
|
||||
"guest_cta": {
|
||||
"title": "Will you organize the next get-together?",
|
||||
"description": "Create your event and share one link, just like this one.",
|
||||
"action": "Create my event"
|
||||
},
|
||||
"demo": {
|
||||
"meta_title": "Kankwa Hub demo",
|
||||
"meta_description": "See how a Kankwa Hub brings every event tool together in one link for guests.",
|
||||
"eyebrow": "No-account demo",
|
||||
"title": "One event, one link",
|
||||
"description": "Switch between guest and organizer views to understand the flow before creating an account.",
|
||||
"choose_view": "Choose the demo view",
|
||||
"guest_view": "Guest view",
|
||||
"owner_view": "Organizer view",
|
||||
"preview_label": "Demo Hub preview",
|
||||
"shared_page": "Link received by guests",
|
||||
"owner_space": "Organizer space",
|
||||
"event_name": "Weekend at Lea’s",
|
||||
"guest_hint": "No account needed: open the useful tool and take part.",
|
||||
"owner_hint": "Add, manage and share every tool from this page.",
|
||||
"kal": "Choose the date that works for the group.",
|
||||
"kdo": "View gift ideas without revealing reservations.",
|
||||
"kount": "Add an expense and track balances.",
|
||||
"participate": "Take part →",
|
||||
"manage": "Manage →",
|
||||
"ready": "Ready to bring your next get-together together?",
|
||||
"create_event": "Create my event"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@
|
|||
"submit": "Create project",
|
||||
"error": "Failed to create project"
|
||||
},
|
||||
"onboarding": {
|
||||
"eyebrow": "Getting started",
|
||||
"title": "Your event is ready",
|
||||
"description": "Add your first tool, then share one link with your guests.",
|
||||
"dismiss": "Hide this guide",
|
||||
"step": "Step {{number}}",
|
||||
"hub_created": "Create the Hub",
|
||||
"add_module": "Add your first tool",
|
||||
"share": "Share with guests",
|
||||
"copy_link": "Copy link",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"hub_card": {
|
||||
"services_count": "{{count}} service",
|
||||
"services_count_other": "{{count}} services",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
"terms": "CGU"
|
||||
},
|
||||
"vitrine": {
|
||||
"create_event": "Créer mon événement",
|
||||
"view_demo": "Voir la démo",
|
||||
"start_free": "Commencer gratuitement",
|
||||
"login": "Se connecter",
|
||||
"register_free": "Créer un compte gratuit",
|
||||
|
|
@ -204,9 +206,10 @@
|
|||
"subheading": "Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit. Vos invités participent sans installer quoi que ce soit et sans inscription obligatoire.",
|
||||
"meta_title": "Organisez vos moments entre proches",
|
||||
"meta_desc": "Listes de cadeaux, sondages, disponibilités, partage de frais — tout au même endroit. Vos invités participent sans créer de compte. Sans pub.",
|
||||
"cta_logged": "Accéder à mon espace →",
|
||||
"cta_register": "Commencer gratuitement",
|
||||
"cta_logged": "Créer mon événement",
|
||||
"cta_register": "Créer mon événement",
|
||||
"cta_login": "Se connecter",
|
||||
"cta_demo": "Voir comment ça marche →",
|
||||
"note": "Sans pub · Vos données vous appartiennent",
|
||||
"problem_q": "Combien d'applis pour organiser un moment entre proches ?",
|
||||
"problem_body": "Une pour trouver la date, une pour les dépenses, une liste de cadeaux quelque part, et un groupe de discussions qui part dans tous les sens. Kankwa rassemble tout.",
|
||||
|
|
@ -232,5 +235,33 @@
|
|||
"why2_body": "Vos listes, vos dépenses, vos disponibilités sont les vôtres. Nous n'avons aucun intérêt à les revendre — et nous l'avons conçu ainsi dès le départ. Aucune publicité, jamais.",
|
||||
"why3_title": "Vos invités participent sans créer de compte.",
|
||||
"why3_body": "Partagez un lien. C'est tout. Pas besoin d'installer quoi que ce soit. Si vous êtes connecté, retrouvez toutes vos contributions en un clin d'œil."
|
||||
},
|
||||
"guest_cta": {
|
||||
"title": "À vous d’organiser le prochain moment ?",
|
||||
"description": "Créez votre événement et partagez un seul lien, comme celui-ci.",
|
||||
"action": "Créer mon événement"
|
||||
},
|
||||
"demo": {
|
||||
"meta_title": "Démo du Hub Kankwa",
|
||||
"meta_description": "Découvrez comment un Hub Kankwa rassemble les outils d’un événement dans un seul lien pour les invités.",
|
||||
"eyebrow": "Démo sans compte",
|
||||
"title": "Un événement, un seul lien",
|
||||
"description": "Passez de la vue invité à la vue organisateur pour comprendre le parcours avant de créer un compte.",
|
||||
"choose_view": "Choisir la vue de démonstration",
|
||||
"guest_view": "Vue invité",
|
||||
"owner_view": "Vue organisateur",
|
||||
"preview_label": "Aperçu du Hub de démonstration",
|
||||
"shared_page": "Lien reçu par les invités",
|
||||
"owner_space": "Espace de l’organisateur",
|
||||
"event_name": "Week-end chez Léa",
|
||||
"guest_hint": "Aucun compte à créer : ouvrez l’outil utile et participez.",
|
||||
"owner_hint": "Ajoutez, gérez et partagez tous les outils depuis cette page.",
|
||||
"kal": "Choisir la date qui convient au groupe.",
|
||||
"kdo": "Voir les idées cadeaux sans révéler les réservations.",
|
||||
"kount": "Ajouter une dépense et suivre les équilibres.",
|
||||
"participate": "Participer →",
|
||||
"manage": "Gérer →",
|
||||
"ready": "Prêt à centraliser votre prochain moment ?",
|
||||
"create_event": "Créer mon événement"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@
|
|||
"submit": "Créer le projet",
|
||||
"error": "Erreur lors de la création"
|
||||
},
|
||||
"onboarding": {
|
||||
"eyebrow": "Mise en route",
|
||||
"title": "Votre événement est créé",
|
||||
"description": "Ajoutez un premier outil, puis partagez un seul lien avec vos invités.",
|
||||
"dismiss": "Masquer le guide",
|
||||
"step": "Étape {{number}}",
|
||||
"hub_created": "Créer le Hub",
|
||||
"add_module": "Ajouter un premier outil",
|
||||
"share": "Partager avec les invités",
|
||||
"copy_link": "Copier le lien",
|
||||
"preview": "Prévisualiser"
|
||||
},
|
||||
"hub_card": {
|
||||
"services_count": "{{count}} service",
|
||||
"services_count_other": "{{count}} services",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
detachService, getEvent,
|
||||
updateServiceVisibility,
|
||||
type AttachedService, type EventDetail, type EventNotification,
|
||||
} from '@services/hub/api/hub'
|
||||
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, LocationChipIcon, MetaChip, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
|
||||
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, CopyButton, LocationChipIcon, MetaChip, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { EVENT_TYPE_ICONS } from '@shared/types'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
|
|
@ -106,11 +106,13 @@ export default function HubEdit() {
|
|||
const { t } = useTranslation('hub')
|
||||
const { user } = useAuth()
|
||||
const { eventId } = useParams<{ eventId: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [event, setEvent] = useState<EventDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [onboardingDismissed, setOnboardingDismissed] = useState(false)
|
||||
usePageTitle(event ? `${event.title} — Hub` : undefined)
|
||||
const [ActiveServiceModal, setActiveServiceModal] = useState<ComponentType<{ onClose: () => void; eventId?: string }> | null>(null)
|
||||
|
||||
|
|
@ -125,6 +127,8 @@ export default function HubEdit() {
|
|||
const publicUrl = `${window.location.origin}/hub/p/${event.share_token}`
|
||||
const eventActions = services.filter(s => s.active && s.eventActions?.length)
|
||||
.flatMap(s => s.eventActions!.map(action => ({ ...action, serviceIcon: s.icon, serviceLabel: s.label })))
|
||||
const hasFirstModule = event.services.length > 0
|
||||
const showOnboarding = !onboardingDismissed && (searchParams.get('onboarding') === '1' || !hasFirstModule)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
|
|
@ -140,6 +144,51 @@ export default function HubEdit() {
|
|||
onEdit={() => setShowEditModal(true)}
|
||||
/>
|
||||
|
||||
{showOnboarding && (
|
||||
<section className="mb-6 rounded-2xl border border-primary-200 bg-primary-50 p-5" aria-labelledby="onboarding-title">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-primary-600">{t('onboarding.eyebrow')}</p>
|
||||
<h2 id="onboarding-title" className="font-bold text-gray-900 mt-1">{t('onboarding.title')}</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{t('onboarding.description')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOnboardingDismissed(true)} className="text-gray-400 hover:text-gray-700" aria-label={t('onboarding.dismiss')}>×</button>
|
||||
</div>
|
||||
|
||||
<ol className="grid gap-3 md:grid-cols-3">
|
||||
<li className="rounded-xl border border-success-200 bg-white p-4">
|
||||
<span className="text-xs font-semibold text-success-700">✓ {t('onboarding.step', { number: 1 })}</span>
|
||||
<p className="font-medium text-gray-900 mt-1">{t('onboarding.hub_created')}</p>
|
||||
</li>
|
||||
<li className={`rounded-xl border bg-white p-4 ${hasFirstModule ? 'border-success-200' : 'border-primary-300'}`}>
|
||||
<span className={`text-xs font-semibold ${hasFirstModule ? 'text-success-700' : 'text-primary-700'}`}>
|
||||
{hasFirstModule ? '✓ ' : ''}{t('onboarding.step', { number: 2 })}
|
||||
</span>
|
||||
<p className="font-medium text-gray-900 mt-1 mb-3">{t('onboarding.add_module')}</p>
|
||||
{!hasFirstModule && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eventActions.map(action => (
|
||||
<button key={action.serviceLabel} type="button" onClick={() => setActiveServiceModal(() => action.modal)} className="btn-primary btn-sm gap-1.5">
|
||||
<span>{action.serviceIcon}</span>{action.serviceLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
<li className={`rounded-xl border bg-white p-4 ${hasFirstModule ? 'border-primary-300' : 'border-gray-200 opacity-60'}`}>
|
||||
<span className="text-xs font-semibold text-primary-700">{t('onboarding.step', { number: 3 })}</span>
|
||||
<p className="font-medium text-gray-900 mt-1 mb-3">{t('onboarding.share')}</p>
|
||||
{hasFirstModule && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CopyButton text={publicUrl} label={t('onboarding.copy_link')} />
|
||||
<Link to={`/hub/p/${event.share_token}`} target="_blank" className="btn-secondary btn-sm">{t('onboarding.preview')}</Link>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="layout-service">
|
||||
<div className="service-main">
|
||||
{eventActions.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function HubNew() {
|
|||
location: location || null,
|
||||
description: description || null,
|
||||
})
|
||||
navigate(`/hub/${ev.id}`)
|
||||
navigate(`/hub/${ev.id}?onboarding=1`)
|
||||
} catch (err) {
|
||||
setError(te(extractApiError(err, 'generic')))
|
||||
} finally {
|
||||
|
|
@ -41,48 +41,21 @@ export default function HubNew() {
|
|||
|
||||
return (
|
||||
<main className="page-sm py-10">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">{t('hub_new.title')}</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">{t("hub_new.title")}</h1>
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3" aria-label={t("hub_new.title")}>
|
||||
<Alert type="error" message={error} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('hub_new.name_placeholder')}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
<option value="">{tc('types.event.empty')}</option>
|
||||
{EVENT_TYPE_VALUES.map(value => <option key={value} value={value}>{tc('types.event.' + value)}</option>)}
|
||||
<input className="input" aria-label={t("hub_new.name_placeholder")} placeholder={t("hub_new.name_placeholder")} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<select aria-label={t("create_modal.type_placeholder")} className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
<option value="">{tc("types.event.empty")}</option>
|
||||
{EVENT_TYPE_VALUES.map(value => <option key={value} value={value}>{tc("types.event." + value)}</option>)}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={eventDate}
|
||||
onChange={e => setEventDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('hub_new.location_placeholder')}
|
||||
value={location}
|
||||
onChange={e => setLocation(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
placeholder={t('hub_new.desc_placeholder')}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<input className="input" type="date" aria-label={t("create_modal.date_placeholder")} value={eventDate} onChange={e => setEventDate(e.target.value)} />
|
||||
<input className="input" aria-label={t("hub_new.location_placeholder")} placeholder={t("hub_new.location_placeholder")} value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} aria-label={t("hub_new.desc_placeholder")} placeholder={t("hub_new.desc_placeholder")} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? t('hub_new.creating') : t('hub_new.submit')}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/hub')} className="btn-secondary">
|
||||
{tc('buttons.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? t("hub_new.creating") : t("hub_new.submit")}</button>
|
||||
<button type="button" onClick={() => navigate("/hub")} className="btn-secondary">{tc("buttons.cancel")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buildSlotGrid, formatTime } from "@services/kal/components/slotGridUtils"
|
||||
|
||||
describe("buildSlotGrid", () => {
|
||||
it("builds the location × time-range matrix and retrieves its slots", () => {
|
||||
const grid = buildSlotGrid(
|
||||
[{ id: "slot-1", date: "2026-08-10", time_range: { id: "morning" }, location: { id: "paris" } }] as never,
|
||||
[{ id: "morning", label: "Matin", start_time: "09:00", end_time: "12:00" }] as never,
|
||||
[{ id: "paris", name: "Paris" }, { id: "lyon", name: "Lyon" }] as never,
|
||||
)
|
||||
|
||||
expect(grid.colKeys).toEqual([
|
||||
{ locationId: "paris", timeRangeId: "morning" },
|
||||
{ locationId: "lyon", timeRangeId: "morning" },
|
||||
])
|
||||
expect(grid.getSlot("2026-08-10", grid.colKeys[0])?.id).toBe("slot-1")
|
||||
expect(grid.getSlot("2026-08-10", grid.colKeys[1])).toBeUndefined()
|
||||
})
|
||||
|
||||
it("formats whole and partial hours", () => {
|
||||
expect(formatTime("09:00")).toBe("09h")
|
||||
expect(formatTime("09:30")).toBe("09h30")
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getPublicPoll, submitVote, updateVote, notifyParticipant,
|
||||
type KalPollPublicResponse,
|
||||
} from '@services/kal/api/kal'
|
||||
import { CancelableActionConfirm, CommentBlock, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { CancelableActionConfirm, CommentBlock, GuestConversionCTA, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import VoteSelectionGrid from '@services/kal/components/VoteSelectionGrid'
|
||||
|
|
@ -170,6 +170,7 @@ export default function PollPublic() {
|
|||
{saveMsg.text}
|
||||
</span>
|
||||
)}
|
||||
{saveMsg?.ok && <GuestConversionCTA />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, CancelableActionConfirm, Modal, ProgressBar } from '@shared/components'
|
||||
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal, ProgressBar } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import { createContribution } from '@services/kdo/api/items'
|
||||
|
|
@ -56,6 +56,7 @@ export default function ContributionModal({ item, onClose, onSuccess }: Props) {
|
|||
<Modal onClose={onClose} title={t('contribution_modal.title_done')}>
|
||||
<p className="text-sm text-gray-500 -mt-2 mb-4">{item.name}</p>
|
||||
<CancelableActionConfirm cancelUrl={cancelUrl} />
|
||||
<GuestConversionCTA className="mt-4" />
|
||||
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, CancelableActionConfirm, Modal } from '@shared/components'
|
||||
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import { createReservation, notifyReservation } from '@services/kdo/api/reservations'
|
||||
|
|
@ -55,6 +55,7 @@ export default function ReservationModal({ item, listTitle, onClose, onReserved
|
|||
qrFilename={`reservation-${listTitle.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
onNotify={email => notifyReservation(cancelToken, email)}
|
||||
/>
|
||||
<GuestConversionCTA className="mt-4" />
|
||||
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function Landing() {
|
|||
})
|
||||
const { user } = useAuth()
|
||||
const location = useLocation()
|
||||
const authState = { background: location }
|
||||
const authState = { background: location, next: '/hub/new' }
|
||||
|
||||
const FUTURE_SERVICES: Omit<ServiceCard, 'active'>[] = [
|
||||
{
|
||||
|
|
@ -54,12 +54,12 @@ export default function Landing() {
|
|||
</p>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
{user ? (
|
||||
<Link to="/hub" className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
|
||||
<Link to="/hub/new" className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
|
||||
{t('landing.cta_logged')}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/register" state={authState} className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
|
||||
<Link to="/register?next=%2Fhub%2Fnew" state={authState} className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
|
||||
{t('landing.cta_register')}
|
||||
</Link>
|
||||
<Link to="/login" state={authState} className="bg-white text-gray-700 text-base px-6 py-3 rounded-xl border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
|
|
@ -68,6 +68,7 @@ export default function Landing() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/demo" className="inline-block mt-4 text-sm text-primary-700 hover:underline">{t('landing.cta_demo')}</Link>
|
||||
<p className="mt-5 text-xs text-gray-400 tracking-wide">
|
||||
{t('landing.note')}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function ListNew() {
|
|||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('create_modal.title')}</h1>
|
||||
<form onSubmit={handleSubmit} className="card flex flex-col gap-4">
|
||||
{error && <p className="alert-error">{error}</p>}
|
||||
<input className="input" placeholder={t('create_modal.name_placeholder')} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<input className="input" aria-label={t('create_modal.name_placeholder')} placeholder={t('create_modal.name_placeholder')} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading || !title.trim()} className="btn-primary">{loading ? t('create_modal.creating') : t('create_modal.submit')}</button>
|
||||
<button type="button" onClick={() => navigate('/kdo')} className="btn-secondary">{tc('buttons.cancel')}</button>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { verifyMagicLink } from '@shared/api/auth'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import { safeAuthNext } from '@shared/utils/authRedirect'
|
||||
|
||||
export default function MagicVerify() {
|
||||
const { t } = useTranslation('auth')
|
||||
|
|
@ -16,7 +17,7 @@ export default function MagicVerify() {
|
|||
useEffect(() => {
|
||||
const token = searchParams.get('token')
|
||||
if (!token) { setError(t('magic_verify.invalid')); return }
|
||||
const next = searchParams.get('next') ?? '/kdo'
|
||||
const next = safeAuthNext(searchParams.get('next'))
|
||||
verifyMagicLink(token)
|
||||
.then(data => { setAuth(data.access_token, data.user); navigate(next) })
|
||||
.catch(() => setError(t('magic_verify.invalid')))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, CancelableActionConfirm, Modal } from '@shared/components'
|
||||
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import { createDeclaration, notifyDeclaration } from '@services/kontrib/api/kontrib'
|
||||
|
|
@ -62,6 +62,7 @@ export default function DeclareModal({ item, onClose, onDeclared }: Props) {
|
|||
qrFilename={`declaration-${item.name.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
onNotify={email => notifyDeclaration(cancelToken, email)}
|
||||
/>
|
||||
<GuestConversionCTA className="mt-4" />
|
||||
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getSharedList, createSuggestion,
|
||||
type KontribListPublic, type ItemSummary,
|
||||
} from '@services/kontrib/api/kontrib'
|
||||
import { Alert, CommentBlock, Modal, ProgressBar, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { Alert, CommentBlock, GuestConversionCTA, Modal, ProgressBar, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import QuantityUnitRow from '@services/kontrib/components/QuantityUnitRow'
|
||||
|
|
@ -110,7 +110,12 @@ export default function BringPublicPage() {
|
|||
<main className="page">
|
||||
<ServicePageHeader title={list.title} description={list.description ?? undefined} backPath={state?.from} />
|
||||
|
||||
{successMessage && <div className="alert-success mb-4">{successMessage}</div>}
|
||||
{successMessage && (
|
||||
<>
|
||||
<div className="alert-success mb-4">{successMessage}</div>
|
||||
<GuestConversionCTA className="mb-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="layout-service">
|
||||
<div className="service-main">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { MemberOut } from '../api/kount'
|
||||
import { addExpense, notifyExpense } from '../api/kount'
|
||||
import { CancelableActionConfirm } from '@shared/components'
|
||||
import { CancelableActionConfirm, GuestConversionCTA } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -69,6 +69,7 @@ export default function ExpenseForm({ groupId, members, currency, onAdded, onClo
|
|||
qrFilename={`depense-${title.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
onNotify={email => notifyExpense(cancelToken, email)}
|
||||
/>
|
||||
<GuestConversionCTA className="mt-4" />
|
||||
<button onClick={onClose} className="btn-primary w-full mt-4">{t('expense_form.close')}</button>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getPublicPoll, submitVote, updateVote, notifyParticipant,
|
||||
type KwizPollPublicResponse,
|
||||
} from '@services/kwiz/api/kwiz'
|
||||
import { CancelableActionConfirm, CommentBlock, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { CancelableActionConfirm, CommentBlock, GuestConversionCTA, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import VoteForm from '@services/kwiz/components/VoteForm'
|
||||
|
|
@ -161,6 +161,7 @@ export default function PollPublic() {
|
|||
{saveMsg.text}
|
||||
</span>
|
||||
)}
|
||||
{saveMsg?.ok && <GuestConversionCTA />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export const register = (email: string, password: string) =>
|
|||
export const login = (email: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/login', { email, password }).then(r => r.data)
|
||||
|
||||
export const requestMagicLink = (email: string) =>
|
||||
api.post<{ message: string }>('/auth/magic-link', { email }).then(r => r.data)
|
||||
export const requestMagicLink = (email: string, next = '/hub/new') =>
|
||||
api.post<{ message: string }>('/auth/magic-link', { email, next }).then(r => r.data)
|
||||
|
||||
export const verifyMagicLink = (token: string) =>
|
||||
api.get<TokenResponse>('/auth/magic-link/verify', { params: { token, lang: currentLang() } }).then(r => r.data)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { authPath } from '@shared/utils/authRedirect'
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { user, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
if (isLoading) return <div className="text-center py-24 text-gray-400">{t('public_redirect.loading')}</div>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
if (!user) {
|
||||
const next = `${location.pathname}${location.search}${location.hash}`
|
||||
return <Navigate to={authPath('/login', next)} replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
|
|||
30
frontend/src/shared/components/ErrorBoundary.tsx
Normal file
30
frontend/src/shared/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
type Props = { children: ReactNode }
|
||||
type State = { hasError: boolean }
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Ne pas exposer l'erreur à l'utilisateur ; la console reste exploitable en support.
|
||||
console.error('Erreur de rendu Kankwa', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Une erreur est survenue</h1>
|
||||
<p className="text-sm text-gray-600">La page n’a pas pu être affichée. Vous pouvez la recharger sans perdre vos données enregistrées.</p>
|
||||
<button className="btn-primary" onClick={() => window.location.reload()}>Recharger la page</button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
30
frontend/src/shared/components/GuestConversionCTA.test.tsx
Normal file
30
frontend/src/shared/components/GuestConversionCTA.test.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../i18n/config'
|
||||
import { AuthProvider, useAuth } from '@shared/hooks/useAuth'
|
||||
import type { User } from '@shared/types'
|
||||
import GuestConversionCTA from './GuestConversionCTA'
|
||||
|
||||
const user: User = {
|
||||
id: 'guest-conversion-user', email: 'member@example.test', lang: 'fr', email_verified: true,
|
||||
is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null,
|
||||
cancel_at_period_end: false, has_password: true, created_at: '2026-08-10T00:00:00Z',
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { setAuth } = useAuth()
|
||||
return <><GuestConversionCTA /><button onClick={() => setAuth('token', user)}>authenticate</button></>
|
||||
}
|
||||
|
||||
describe('GuestConversionCTA', () => {
|
||||
it('is proposed after a guest action and disappears for authenticated users', () => {
|
||||
localStorage.clear()
|
||||
render(<MemoryRouter><AuthProvider><Probe /></AuthProvider></MemoryRouter>)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Créer mon événement' })).toHaveAttribute('href', '/register?next=%2Fhub%2Fnew')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'authenticate' }))
|
||||
expect(screen.queryByRole('link', { name: 'Créer mon événement' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
25
frontend/src/shared/components/GuestConversionCTA.tsx
Normal file
25
frontend/src/shared/components/GuestConversionCTA.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
|
||||
export default function GuestConversionCTA({ className = '' }: { className?: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { user } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (user) return null
|
||||
|
||||
return (
|
||||
<aside className={`rounded-xl border border-primary-100 bg-primary-50 p-4 ${className}`} aria-label={t('guest_cta.title')}>
|
||||
<p className="text-sm font-semibold text-gray-900">{t('guest_cta.title')}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 mb-3">{t('guest_cta.description')}</p>
|
||||
<Link
|
||||
to="/register?next=%2Fhub%2Fnew"
|
||||
state={{ background: location, next: '/hub/new' }}
|
||||
className="btn-primary btn-sm inline-flex"
|
||||
>
|
||||
{t('guest_cta.action')}
|
||||
</Link>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
32
frontend/src/shared/components/Modal.test.tsx
Normal file
32
frontend/src/shared/components/Modal.test.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Modal from './Modal'
|
||||
|
||||
describe('Modal', () => {
|
||||
it('expose un dialogue accessible, piège le focus et se ferme avec Échap', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
trigger.focus()
|
||||
|
||||
const { unmount } = render(
|
||||
<Modal title="Titre de test" onClose={onClose}>
|
||||
<input aria-label="Champ de test" />
|
||||
<button>Valider</button>
|
||||
</Modal>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Titre de test' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveFocus())
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
expect(trigger).toHaveFocus()
|
||||
trigger.remove()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
|
@ -13,53 +13,75 @@ interface Props {
|
|||
export default function Modal({ onClose, children, title, disableBackdropClose, compact = false }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const backdropRef = useRef<HTMLDivElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const mouseDownOnBackdrop = useRef(false)
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (backdropRef.current) backdropRef.current.scrollTop = 0
|
||||
}, [])
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
const focusable = () => Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])
|
||||
const focusInitial = () => focusable()[0]?.focus()
|
||||
const timer = window.setTimeout(focusInitial, 0)
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = focusable()
|
||||
if (!elements.length) return
|
||||
const first = elements[0]
|
||||
const last = elements[elements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
previousFocus?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleBackdropMouseDown = (e: React.MouseEvent) => {
|
||||
mouseDownOnBackdrop.current = e.target === e.currentTarget
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (!disableBackdropClose && e.target === e.currentTarget && mouseDownOnBackdrop.current) {
|
||||
onClose()
|
||||
}
|
||||
if (!disableBackdropClose && e.target === e.currentTarget && mouseDownOnBackdrop.current) onClose()
|
||||
mouseDownOnBackdrop.current = false
|
||||
}
|
||||
|
||||
const backdropCls = compact
|
||||
? 'fixed inset-0 z-50 overflow-y-auto bg-black/40 flex items-center justify-center p-4'
|
||||
: 'fixed inset-0 z-50 overflow-y-auto flex items-start sm:bg-black/40 sm:items-center sm:justify-center sm:p-4'
|
||||
|
||||
const contentCls = compact
|
||||
? 'relative bg-white rounded-2xl p-6 w-full max-w-sm shadow-xl my-auto'
|
||||
: 'relative bg-white w-full min-h-screen p-4 pb-10 sm:min-h-0 sm:rounded-2xl sm:p-6 sm:max-w-sm sm:shadow-xl sm:my-auto'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className={backdropCls}
|
||||
onMouseDown={handleBackdropMouseDown}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div ref={backdropRef} className={backdropCls} onMouseDown={handleBackdropMouseDown} onClick={handleBackdropClick}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={contentCls}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-label={title ? undefined : t('buttons.close')}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
{title
|
||||
? <h2 className="text-lg font-semibold text-gray-900 pr-2">{title}</h2>
|
||||
: <span />
|
||||
}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-gray-400 hover:text-gray-600 p-1 -mt-1 -mr-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label={t('buttons.close')}
|
||||
>
|
||||
{title ? <h2 id={titleId} className="text-lg font-semibold text-gray-900 pr-2">{title}</h2> : <span />}
|
||||
<button onClick={onClose} className="shrink-0 text-gray-400 hover:text-gray-600 p-1 -mt-1 -mr-1 rounded-lg hover:bg-gray-100 transition-colors" aria-label={t('buttons.close')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className="w-5 h-5">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
|
@ -68,6 +90,6 @@ export default function Modal({ onClose, children, title, disableBackdropClose,
|
|||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
|
||||
interface Feature {
|
||||
icon: ReactNode
|
||||
|
|
@ -27,7 +28,9 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
|
|||
const { t } = useTranslation('common')
|
||||
usePageMeta({ title: label, description: tagline })
|
||||
const location = useLocation()
|
||||
const authState = { background: location }
|
||||
const { user } = useAuth()
|
||||
const authState = { background: location, next: '/hub/new' }
|
||||
const createPath = user ? '/hub/new' : '/register?next=%2Fhub%2Fnew'
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-4 py-12">
|
||||
|
|
@ -42,11 +45,11 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
|
|||
<p className="text-base text-gray-500 max-w-xl mx-auto mb-8 leading-relaxed">{description}</p>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
<Link
|
||||
to="/register"
|
||||
to={createPath}
|
||||
state={authState}
|
||||
className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium"
|
||||
>
|
||||
{t('vitrine.start_free')}
|
||||
{t('vitrine.create_event')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
|
|
@ -55,6 +58,9 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
|
|||
>
|
||||
{t('vitrine.login')}
|
||||
</Link>
|
||||
<Link to="/demo" className="text-primary-700 text-sm px-4 py-3 hover:underline">
|
||||
{t('vitrine.view_demo')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-400">{t('vitrine.no_ads')}</p>
|
||||
</section>
|
||||
|
|
@ -103,11 +109,11 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
|
|||
<p className="font-bold text-gray-900 text-lg mb-6">{t('vitrine.cta_ready')}</p>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
<Link
|
||||
to="/register"
|
||||
to={createPath}
|
||||
state={authState}
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
{t('vitrine.register_free')}
|
||||
{t('vitrine.create_event')}
|
||||
</Link>
|
||||
<Link to="/" className="text-sm text-gray-500 px-6 py-3 hover:text-gray-800 transition-colors">
|
||||
{t('vitrine.back_home')}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,5 @@ export { default as ServiceRoute } from './ServiceRoute'
|
|||
export { default as SwipeToDelete } from './SwipeToDelete'
|
||||
export { default as ServiceVitrine } from './ServiceVitrine'
|
||||
export { default as PublicRedirectGuard } from './PublicRedirectGuard'
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary"
|
||||
export { default as GuestConversionCTA } from './GuestConversionCTA'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
{ "loc": "/kal", "priority": "0.8", "changefreq": "weekly" },
|
||||
{ "loc": "/kwiz", "priority": "0.8", "changefreq": "weekly" },
|
||||
{ "loc": "/hub", "priority": "0.8", "changefreq": "weekly" },
|
||||
{ "loc": "/demo", "priority": "0.8", "changefreq": "monthly" },
|
||||
{ "loc": "/blog", "priority": "0.7", "changefreq": "weekly" },
|
||||
{ "loc": "/legal", "priority": "0.3", "changefreq": "monthly" },
|
||||
{ "loc": "/privacy", "priority": "0.3", "changefreq": "monthly" },
|
||||
|
|
|
|||
48
frontend/src/shared/hooks/__tests__/useAuth.test.tsx
Normal file
48
frontend/src/shared/hooks/__tests__/useAuth.test.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { AuthProvider, useAuth } from "@shared/hooks/useAuth"
|
||||
import type { User } from "@shared/types"
|
||||
|
||||
const user: User = {
|
||||
id: "test-user",
|
||||
email: "member@example.com",
|
||||
lang: "fr",
|
||||
email_verified: true,
|
||||
is_premium: false,
|
||||
premium_until: null,
|
||||
subscription_plan: null,
|
||||
subscription_status: null,
|
||||
cancel_at_period_end: false,
|
||||
has_password: true,
|
||||
created_at: "2026-08-10T00:00:00Z",
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { user: currentUser, isLoading, setAuth, logout } = useAuth()
|
||||
return (
|
||||
<>
|
||||
<span data-testid="loading">{String(isLoading)}</span>
|
||||
<span data-testid="email">{currentUser?.email ?? "anonymous"}</span>
|
||||
<button onClick={() => setAuth("token", user)}>login</button>
|
||||
<button onClick={logout}>logout</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it("persists and clears an authenticated session", () => {
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false")
|
||||
fireEvent.click(screen.getByRole("button", { name: "login" }))
|
||||
expect(localStorage.getItem("token")).toBe("token")
|
||||
expect(screen.getByTestId("email")).toHaveTextContent("member@example.com")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "logout" }))
|
||||
expect(localStorage.getItem("token")).toBeNull()
|
||||
expect(screen.getByTestId("email")).toHaveTextContent("anonymous")
|
||||
})
|
||||
})
|
||||
66
frontend/src/shared/pages/Demo.tsx
Normal file
66
frontend/src/shared/pages/Demo.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
|
||||
export default function Demo() {
|
||||
const { t } = useTranslation('common')
|
||||
const [view, setView] = useState<'guest' | 'owner'>('guest')
|
||||
usePageMeta({ title: t('demo.meta_title'), description: t('demo.meta_description') })
|
||||
|
||||
const modules = [
|
||||
{ icon: '📅', name: 'Kal', text: t('demo.kal') },
|
||||
{ icon: '🎁', name: 'Kdo', text: t('demo.kdo') },
|
||||
{ icon: '💶', name: 'Kount', text: t('demo.kount') },
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-12">
|
||||
<header className="text-center max-w-2xl mx-auto mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-primary-600">{t('demo.eyebrow')}</p>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mt-2">{t('demo.title')}</h1>
|
||||
<p className="text-gray-600 mt-3">{t('demo.description')}</p>
|
||||
</header>
|
||||
|
||||
<div className="flex justify-center gap-2 mb-5" role="group" aria-label={t('demo.choose_view')}>
|
||||
{(['guest', 'owner'] as const).map(option => (
|
||||
<button key={option} type="button" onClick={() => setView(option)} className={view === option ? 'btn-primary btn-sm' : 'btn-secondary btn-sm'} aria-pressed={view === option}>
|
||||
{t(`demo.${option}_view`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden" aria-label={t('demo.preview_label')}>
|
||||
<div className="bg-primary-600 text-white p-6 sm:p-8">
|
||||
<p className="text-xs text-primary-100">{view === 'guest' ? t('demo.shared_page') : t('demo.owner_space')}</p>
|
||||
<h2 className="text-2xl font-bold mt-1">{t('demo.event_name')}</h2>
|
||||
<p className="text-sm text-primary-50 mt-2">📍 Lyon · 14 septembre</p>
|
||||
</div>
|
||||
<div className="p-5 sm:p-8">
|
||||
<div className="mb-5 rounded-xl bg-gray-50 border border-gray-100 p-4 text-sm text-gray-700">
|
||||
{view === 'guest' ? t('demo.guest_hint') : t('demo.owner_hint')}
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
{modules.map(module => (
|
||||
<article key={module.name} className="rounded-xl border border-gray-200 p-4">
|
||||
<span className="text-2xl" aria-hidden="true">{module.icon}</span>
|
||||
<h3 className="font-semibold text-gray-900 mt-2">{module.name}</h3>
|
||||
<p className="text-xs text-gray-600 mt-1 min-h-10">{module.text}</p>
|
||||
<span className="inline-block mt-3 text-sm font-medium text-primary-700">
|
||||
{view === 'guest' ? t('demo.participate') : t('demo.manage')}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="text-center mt-8">
|
||||
<p className="font-semibold text-gray-900 mb-4">{t('demo.ready')}</p>
|
||||
<Link to="/register?next=%2Fhub%2Fnew" state={{ next: '/hub/new' }} className="btn-primary inline-flex px-6 py-3">
|
||||
{t('demo.create_event')}
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useAuth } from '@shared/hooks/useAuth'
|
|||
import { Alert, Modal } from '@shared/components'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import type { ApiError } from '@shared/types'
|
||||
import { authNextFromLocation, authPath } from '@shared/utils/authRedirect'
|
||||
|
||||
export default function LoginModal() {
|
||||
const { t } = useTranslation('auth')
|
||||
|
|
@ -14,6 +15,7 @@ export default function LoginModal() {
|
|||
const { setAuth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
|
||||
const hasBackground = !!(location.state as any)?.background
|
||||
const handleClose = () => hasBackground ? navigate(-1) : navigate('/')
|
||||
|
||||
|
|
@ -36,7 +38,7 @@ export default function LoginModal() {
|
|||
try {
|
||||
const data = await login(email, password)
|
||||
setAuth(data.access_token, data.user)
|
||||
navigate('/')
|
||||
navigate(next, { replace: true })
|
||||
} catch (err) {
|
||||
const detail = (err as ApiError)?.response?.data?.detail
|
||||
if (detail === 'email_not_verified') {
|
||||
|
|
@ -50,7 +52,7 @@ export default function LoginModal() {
|
|||
const handleMagicLink = async () => {
|
||||
if (!email) { setError(t('login.email_required')); return }
|
||||
setMagicLoading(true); setError('')
|
||||
try { await requestMagicLink(email); setMagicSent(true) }
|
||||
try { await requestMagicLink(email, next); setMagicSent(true) }
|
||||
catch { setError(t('login.magic_link_error')) }
|
||||
finally { setMagicLoading(false) }
|
||||
}
|
||||
|
|
@ -61,7 +63,7 @@ export default function LoginModal() {
|
|||
try {
|
||||
const data = await verifyEmail(email, code)
|
||||
setAuth(data.access_token, data.user)
|
||||
navigate('/')
|
||||
navigate(next, { replace: true })
|
||||
} catch {
|
||||
setVerifyError(te('invalid_or_expired_code'))
|
||||
} finally { setVerifyLoading(false) }
|
||||
|
|
@ -119,7 +121,7 @@ export default function LoginModal() {
|
|||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
{t('login.no_account')}{' '}
|
||||
<Link to="/register" state={(location.state as any)} className="text-primary-600 hover:underline">{t('login.register_link')}</Link>
|
||||
<Link to={authPath('/register', next)} state={(location.state as any)} className="text-primary-600 hover:underline">{t('login.register_link')}</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ export default function Privacy() {
|
|||
if (i18n.language.startsWith('en')) return (
|
||||
<main className="max-w-2xl mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">Privacy policy</h1>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Data collected</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data required to operate the service:</p><ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1"><li>Email address (account identifier)</li><li>Hashed password (never stored in plain text)</li><li>Content created on the platform, such as lists and events</li></ul></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Data collected</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data required to operate the service:</p><ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1"><li>Email address (account identifier)</li><li>Hashed password (never stored in plain text)</li><li>Content created on the platform, such as lists and events</li><li>Subscription references managed by Stripe (customer and subscription identifiers)</li></ul></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Use of data</h2><p className="text-sm text-gray-600 leading-relaxed">Your data is used exclusively to operate the service. It is never sold, shared with third parties, or used for advertising or targeting.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Cookies and tracking</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa uses no tracking cookies or third-party behavioral analytics. Only an authentication token is stored locally in your browser to maintain your session.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Hosting and security</h2><p className="text-sm text-gray-600 leading-relaxed">All data is hosted on a private server in Europe, with no transfer to third countries. Communications are encrypted via HTTPS and passwords are hashed with bcrypt.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Your GDPR rights</h2><p className="text-sm text-gray-600 leading-relaxed">You have the right to access, correct and delete your personal data. To exercise these rights, contact <a href="mailto:contact@kankwa.fr" className="text-primary-600 hover:underline">contact@kankwa.fr</a>.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Retention period</h2><p className="text-sm text-gray-600 leading-relaxed">Data is retained while the account remains active. When an account is deleted, all associated data is erased within 30 days.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Retention period</h2><p className="text-sm text-gray-600 leading-relaxed">Data is retained while the account remains active. When an account is deleted, its active Kankwa+ subscription is cancelled immediately and associated Kankwa data is erased within 30 days. Stripe may retain payment records for its legal and accounting obligations.</p></section>
|
||||
</main>
|
||||
)
|
||||
return (
|
||||
|
|
@ -27,7 +27,7 @@ export default function Privacy() {
|
|||
<ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1">
|
||||
<li>Adresse email (identifiant de compte)</li>
|
||||
<li>Mot de passe hashé (jamais stocké en clair)</li>
|
||||
<li>Contenus créés sur la plateforme (listes, événements, etc.)</li>
|
||||
<li>Contenus créés sur la plateforme (listes, événements, etc.)</li><li>Références d’abonnement gérées par Stripe (identifiants client et abonnement)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ export default function Privacy() {
|
|||
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Durée de conservation</h2>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Les données sont conservées tant que le compte est actif. En cas de suppression de compte,
|
||||
toutes les données associées sont effacées sous 30 jours.
|
||||
tout abonnement Kankwa+ actif est résilié immédiatement et toutes les données Kankwa associées sont effacées sous 30 jours. Stripe peut conserver les pièces et références de paiement nécessaires à ses obligations légales et comptables.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useAuth } from '@shared/hooks/useAuth'
|
|||
import { Alert, Modal } from '@shared/components'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import { authNextFromLocation, authPath } from '@shared/utils/authRedirect'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export default function RegisterModal() {
|
|||
const { setAuth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
|
||||
const hasBackground = !!(location.state as any)?.background
|
||||
const handleClose = () => hasBackground ? navigate(-1) : navigate('/')
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ export default function RegisterModal() {
|
|||
const handleMagicLink = async () => {
|
||||
if (!email) { setError(t('login.email_required')); return }
|
||||
setMagicLoading(true); setError('')
|
||||
try { await requestMagicLink(email); setMagicSent(true) }
|
||||
try { await requestMagicLink(email, next); setMagicSent(true) }
|
||||
catch { setError(t('login.magic_link_error')) }
|
||||
finally { setMagicLoading(false) }
|
||||
}
|
||||
|
|
@ -74,7 +76,7 @@ export default function RegisterModal() {
|
|||
try {
|
||||
const data = await verifyEmail(email, code)
|
||||
setAuth(data.access_token, data.user)
|
||||
navigate('/')
|
||||
navigate(next, { replace: true })
|
||||
} catch {
|
||||
setVerifyError(te('invalid_or_expired_code'))
|
||||
} finally { setVerifyLoading(false) }
|
||||
|
|
@ -99,6 +101,7 @@ export default function RegisterModal() {
|
|||
<form onSubmit={handleVerify} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="input text-center text-2xl tracking-widest font-mono"
|
||||
aria-label={t('verify.title')}
|
||||
placeholder="000000"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
|
|
@ -129,6 +132,7 @@ export default function RegisterModal() {
|
|||
<input
|
||||
className={`input ${emailTouched && !emailValid ? 'border-danger-600 focus:ring-danger-100' : ''}`}
|
||||
type="email"
|
||||
aria-label={t('register.email_placeholder')}
|
||||
placeholder={t('register.email_placeholder')}
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
|
|
@ -145,6 +149,7 @@ export default function RegisterModal() {
|
|||
<input
|
||||
className={`input ${passwordTouched && !passwordValid ? 'border-danger-600 focus:ring-danger-100' : ''}`}
|
||||
type="password"
|
||||
aria-label={t('register.password_placeholder')}
|
||||
placeholder={t('register.password_placeholder')}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
|
|
@ -176,7 +181,7 @@ export default function RegisterModal() {
|
|||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
{t('register.already_account')}{' '}
|
||||
<Link to="/login" state={(location.state as any)} className="text-primary-600 hover:underline">{t('register.login_link')}</Link>
|
||||
<Link to={authPath('/login', next)} state={(location.state as any)} className="text-primary-600 hover:underline">{t('register.login_link')}</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ export default function Terms() {
|
|||
usePageMeta({ title: t('footer.terms') })
|
||||
if (i18n.language.startsWith('en')) return (
|
||||
<main className="max-w-2xl mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Terms of service</h1><p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Terms of service</h1><p className="text-sm text-gray-400 mb-10">Last updated: August 2026</p>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">1. Purpose</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa is a collaborative services platform available at <strong>kankwa.com</strong>. These terms govern access to and use of the service.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">2. Access to the service</h2><p className="text-sm text-gray-600 leading-relaxed">Basic features are free and can be used without registration. Some advanced features require an account. Users guarantee that registration information is accurate.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">2. Access to the service</h2><p className="text-sm text-gray-600 leading-relaxed">Basic features are free and can be used without registration. Some advanced features require an account. Users guarantee that registration information is accurate.</p></section><section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Kankwa+ subscriptions and account deletion</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa+ subscriptions are managed by Stripe. Deleting an account cancels any active subscription immediately, without an automatic refund for the current period; access to Kankwa+ ends immediately. Stripe may retain payment records as required by law.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Acceptable use</h2><p className="text-sm text-gray-600 leading-relaxed">Users must use Kankwa lawfully and must not publish illegal or defamatory content, infringe third-party rights, circumvent security measures, or use the service commercially without prior agreement.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">4. Personal data</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data strictly required to operate the service. No data is sold or provided to third parties for advertising. Contact <a href="mailto:contact@kankwa.fr" className="text-primary-600 hover:underline">contact@kankwa.fr</a> or read our <a href="/privacy" className="text-primary-600 hover:underline">privacy policy</a>.</p></section>
|
||||
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">5. Affiliate links</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa may generate affiliate links to merchant websites when a product link is added to a list. Kankwa may receive a commission at no additional cost to the buyer.</p></section>
|
||||
|
|
@ -20,7 +20,7 @@ export default function Terms() {
|
|||
return (
|
||||
<main className="max-w-2xl mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Conditions générales d'utilisation</h1>
|
||||
<p className="text-sm text-gray-400 mb-10">Dernière mise à jour : mai 2026</p>
|
||||
<p className="text-sm text-gray-400 mb-10">Dernière mise à jour : août 2026</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">1. Objet</h2>
|
||||
|
|
@ -39,7 +39,14 @@ export default function Terms() {
|
|||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Usage acceptable</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Abonnement Kankwa+ et suppression de compte</h2>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Les abonnements Kankwa+ sont gérés par Stripe. La suppression d’un compte résilie immédiatement tout abonnement actif, sans remboursement automatique de la période en cours ; l’accès à Kankwa+ prend fin immédiatement. Stripe peut conserver les pièces de paiement nécessaires à ses obligations légales.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Usage acceptable</h2>)
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-3">
|
||||
L'utilisateur s'engage à utiliser kankwa de manière licite et à ne pas :
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { verifyEmail, resendVerification } from '@shared/api/auth'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { Alert } from '@shared/components'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import { authNextFromLocation } from '@shared/utils/authRedirect'
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { t } = useTranslation('auth')
|
||||
|
|
@ -12,6 +13,8 @@ export default function VerifyEmail() {
|
|||
usePageTitle(t('verify_email_page.title'))
|
||||
const { user, refreshUser } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
|
||||
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -26,7 +29,7 @@ export default function VerifyEmail() {
|
|||
try {
|
||||
await verifyEmail(user.email, code)
|
||||
await refreshUser()
|
||||
navigate('/kdo')
|
||||
navigate(next, { replace: true })
|
||||
} catch {
|
||||
setError(te('invalid_or_expired_code'))
|
||||
} finally { setLoading(false) }
|
||||
|
|
|
|||
20
frontend/src/shared/utils/authRedirect.test.ts
Normal file
20
frontend/src/shared/utils/authRedirect.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { authNextFromLocation, authPath, safeAuthNext } from './authRedirect'
|
||||
|
||||
describe('auth redirects', () => {
|
||||
it('preserves an internal destination across auth pages', () => {
|
||||
expect(authNextFromLocation('?next=%2Fhub%2Fnew')).toBe('/hub/new')
|
||||
expect(authPath('/register', '/hub/new')).toBe('/register?next=%2Fhub%2Fnew')
|
||||
})
|
||||
|
||||
it('rejects external and auth-loop destinations', () => {
|
||||
expect(safeAuthNext('//evil.example/path')).toBe('/hub/new')
|
||||
expect(safeAuthNext('https://evil.example/path')).toBe('/hub/new')
|
||||
expect(safeAuthNext('/login')).toBe('/hub/new')
|
||||
})
|
||||
|
||||
it('keeps the query string and hash of a valid destination', () => {
|
||||
expect(safeAuthNext('/hub/123?onboarding=1#share')).toBe('/hub/123?onboarding=1#share')
|
||||
})
|
||||
})
|
||||
23
frontend/src/shared/utils/authRedirect.ts
Normal file
23
frontend/src/shared/utils/authRedirect.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const DEFAULT_AUTH_NEXT = '/hub/new'
|
||||
|
||||
export function safeAuthNext(value: string | null | undefined, fallback = DEFAULT_AUTH_NEXT) {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback
|
||||
|
||||
try {
|
||||
const url = new URL(value, window.location.origin)
|
||||
if (url.origin !== window.location.origin) return fallback
|
||||
if (url.pathname === '/login' || url.pathname === '/register') return fallback
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function authNextFromLocation(search: string, state?: { next?: string } | null) {
|
||||
const fromQuery = new URLSearchParams(search).get('next')
|
||||
return safeAuthNext(fromQuery ?? state?.next)
|
||||
}
|
||||
|
||||
export function authPath(path: '/login' | '/register', next: string) {
|
||||
return `${path}?next=${encodeURIComponent(safeAuthNext(next))}`
|
||||
}
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "@testing-library/jest-dom/vitest"
|
||||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/api': process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
19
frontend/vitest.config.ts
Normal file
19
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { resolve } from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve(__dirname, "src/shared"),
|
||||
"@services": resolve(__dirname, "src/services"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
clearMocks: true,
|
||||
},
|
||||
})
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
17
scripts/backup-postgres.sh
Executable file
17
scripts/backup-postgres.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Usage: $0 /chemin/kankwa-AAAA-MM-JJ.sql.gz" >&2
|
||||
exit 64
|
||||
fi
|
||||
output=$1
|
||||
case "$output" in
|
||||
*.sql.gz) ;;
|
||||
*) echo "La sauvegarde doit finir par .sql.gz" >&2; exit 64 ;;
|
||||
esac
|
||||
umask 077
|
||||
directory=$(dirname -- "$output")
|
||||
mkdir -p -- "$directory"
|
||||
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip -9 > "$output"
|
||||
printf 'Sauvegarde créée : %s\n' "$output"
|
||||
11
scripts/restore-postgres.sh
Executable file
11
scripts/restore-postgres.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "Usage: $0 /chemin/kankwa-AAAA-MM-JJ.sql.gz" >&2
|
||||
exit 64
|
||||
fi
|
||||
printf 'Cette opération remplace les données de la base de production. Tapez RESTAURER : '
|
||||
read -r confirmation
|
||||
[ "$confirmation" = "RESTAURER" ] || { echo "Annulé."; exit 1; }
|
||||
gzip -dc -- "$1" | docker compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||
|
|
@ -1,35 +1,21 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
from shared.validation import Name100, ResourceType50, Text10000
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
author_name: str
|
||||
content: str
|
||||
|
||||
@field_validator("author_name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Le prénom ne peut pas être vide")
|
||||
return v[:100]
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def content_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Le commentaire ne peut pas être vide")
|
||||
return v[:2000]
|
||||
author_name: Name100
|
||||
content: Text10000
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: UUID
|
||||
resource_type: str
|
||||
resource_type: ResourceType50
|
||||
resource_id: UUID
|
||||
user_id: UUID | None
|
||||
author_name: str
|
||||
|
|
|
|||
|
|
@ -2,25 +2,26 @@ from datetime import date, datetime
|
|||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from shared.validation import EventType50, Text10000, Title255
|
||||
from shared.schemas import MessageResponse # noqa: F401
|
||||
|
||||
|
||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
title: str
|
||||
event_type: str | None = None
|
||||
title: Title255
|
||||
event_type: EventType50 | None = None
|
||||
event_date: date | None = None
|
||||
location: str | None = None
|
||||
description: str | None = None
|
||||
location: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
event_type: str | None = None
|
||||
title: Title255 | None = None
|
||||
event_type: EventType50 | None = None
|
||||
event_date: date | None = None
|
||||
location: str | None = None
|
||||
description: str | None = None
|
||||
location: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
|
||||
|
||||
class AttachedService(BaseModel):
|
||||
|
|
@ -44,10 +45,10 @@ class EventNotificationOut(BaseModel):
|
|||
|
||||
class EventSummary(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
event_type: str | None
|
||||
title: Title255
|
||||
event_type: EventType50 | None
|
||||
event_date: date | None
|
||||
location: str | None
|
||||
location: Title255 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
|
||||
|
|
@ -57,11 +58,11 @@ class EventSummary(BaseModel):
|
|||
class EventResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
event_type: str | None
|
||||
title: Title255
|
||||
event_type: EventType50 | None
|
||||
event_date: date | None
|
||||
location: str | None
|
||||
description: str | None
|
||||
location: Title255 | None
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
services: list[AttachedService]
|
||||
|
|
@ -79,11 +80,11 @@ class AttachedServicePublic(BaseModel):
|
|||
|
||||
class EventPublicResponse(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
event_type: str | None
|
||||
title: Title255
|
||||
event_type: EventType50 | None
|
||||
event_date: date | None
|
||||
location: str | None
|
||||
description: str | None
|
||||
location: Title255 | None
|
||||
description: Text10000 | None
|
||||
services: list[AttachedServicePublic]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
@ -115,7 +116,7 @@ class MyContributionItem(BaseModel):
|
|||
cancel_token: UUID
|
||||
participant_name: str
|
||||
created_at: datetime
|
||||
service_title: str
|
||||
service_title: Title255
|
||||
item_name: str | None = None
|
||||
amount: str | None = None # contribution / kount_expense
|
||||
public_token: UUID | None = None # share_token du service rattaché
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ from datetime import date, datetime, time
|
|||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from shared.validation import Name100, Text10000, Title255
|
||||
from shared.schemas import CancelableActionOut, MessageResponse, NotifyEmailRequest # noqa: F401
|
||||
|
||||
KalAnswer = Literal["yes", "maybe", "no"]
|
||||
|
|
@ -16,39 +17,39 @@ class TimeRangeIn(BaseModel):
|
|||
|
||||
|
||||
class KalPollCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title255
|
||||
description: Text10000 | None = None
|
||||
event_id: UUID | None = None
|
||||
dates: list[date] # max 30
|
||||
time_ranges: list[TimeRangeIn] = [] # max 2
|
||||
locations: list[str] = [] # max 3
|
||||
dates: list[date] = Field(min_length=1, max_length=30) # max 30
|
||||
time_ranges: list[TimeRangeIn] = Field(default_factory=list, max_length=2) # max 2
|
||||
locations: list[Name100] = Field(default_factory=list, max_length=3) # max 3
|
||||
votes_nominatif_public: bool = False
|
||||
|
||||
|
||||
class KalPollConfigUpdate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title255
|
||||
description: Text10000 | None = None
|
||||
votes_nominatif_public: bool = False
|
||||
dates: list[date]
|
||||
time_ranges: list[TimeRangeIn] = []
|
||||
locations: list[str] = []
|
||||
dates: list[date] = Field(min_length=1, max_length=30)
|
||||
time_ranges: list[TimeRangeIn] = Field(default_factory=list, max_length=2)
|
||||
locations: list[Name100] = Field(default_factory=list, max_length=3)
|
||||
|
||||
|
||||
class KalPollUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
votes_nominatif_public: bool | None = None
|
||||
is_closed: bool | None = None
|
||||
|
||||
|
||||
class VoteSubmit(BaseModel):
|
||||
name: str
|
||||
name: Name100
|
||||
email: EmailStr | None = None
|
||||
answers: dict[UUID, KalAnswer] # slot_id → answer (vide = "no")
|
||||
answers: dict[UUID, KalAnswer] = Field(min_length=1, max_length=180) # slot_id → answer (vide = "no")
|
||||
|
||||
|
||||
class VoteUpdate(BaseModel):
|
||||
answers: dict[UUID, KalAnswer]
|
||||
answers: dict[UUID, KalAnswer] = Field(min_length=1, max_length=180)
|
||||
|
||||
|
||||
# ── Responses ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -85,7 +86,7 @@ class ResponseOut(BaseModel):
|
|||
|
||||
class ParticipantOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
name: Name100
|
||||
created_at: datetime
|
||||
responses: list[ResponseOut] = []
|
||||
|
||||
|
|
@ -93,14 +94,14 @@ class ParticipantOut(BaseModel):
|
|||
class ParticipantPublicOut(BaseModel):
|
||||
"""Participant sans réponses détaillées (pour vue publique non nominative)."""
|
||||
id: UUID
|
||||
name: str
|
||||
name: Name100
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class KalPollSummary(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
votes_nominatif_public: bool
|
||||
is_closed: bool
|
||||
|
|
@ -114,8 +115,8 @@ class KalPollSummary(BaseModel):
|
|||
class KalPollResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
votes_nominatif_public: bool
|
||||
is_closed: bool
|
||||
|
|
@ -123,7 +124,7 @@ class KalPollResponse(BaseModel):
|
|||
event_id: UUID | None
|
||||
time_ranges: list[TimeRangeOut]
|
||||
locations: list[LocationOut]
|
||||
dates: list[date]
|
||||
dates: list[date] = Field(min_length=1, max_length=30)
|
||||
slots: list[SlotOut]
|
||||
participants: list[ParticipantOut]
|
||||
has_votes: bool
|
||||
|
|
@ -131,14 +132,14 @@ class KalPollResponse(BaseModel):
|
|||
|
||||
class KalPollPublicResponse(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
votes_nominatif_public: bool
|
||||
is_closed: bool
|
||||
time_ranges: list[TimeRangeOut]
|
||||
locations: list[LocationOut]
|
||||
dates: list[date]
|
||||
dates: list[date] = Field(min_length=1, max_length=30)
|
||||
slots: list[SlotOut]
|
||||
participants: list[ParticipantPublicOut]
|
||||
# réponses détaillées seulement si votes_nominatif_public ou owner
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ async def create_reservation(body: ReservationCreate, current_user=Depends(get_o
|
|||
result = await db.execute(
|
||||
select(KdoItem)
|
||||
.where(KdoItem.id == body.kdo_item_id)
|
||||
.with_for_update()
|
||||
.options(
|
||||
selectinload(KdoItem.reservation),
|
||||
selectinload(KdoItem.kdo_list).selectinload(KdoList.owner),
|
||||
|
|
@ -330,6 +331,7 @@ async def create_contribution(body: ContributionCreate, current_user=Depends(get
|
|||
result = await db.execute(
|
||||
select(KdoItem)
|
||||
.where(KdoItem.id == body.kdo_item_id)
|
||||
.with_for_update()
|
||||
.options(
|
||||
selectinload(KdoItem.contributions),
|
||||
selectinload(KdoItem.kdo_list).selectinload(KdoList.owner),
|
||||
|
|
@ -345,7 +347,7 @@ async def create_contribution(body: ContributionCreate, current_user=Depends(get
|
|||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="positive_amount")
|
||||
|
||||
total = sum(c.amount for c in item.contributions)
|
||||
if item.price and total >= item.price:
|
||||
if item.price and total + body.amount > item.price:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already_fully_funded")
|
||||
|
||||
guest_email = None if current_user else body.participant_email
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ from datetime import datetime
|
|||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from shared.validation import Amount, Name100, Text10000, Title255, Url2048
|
||||
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
|
||||
|
||||
|
||||
|
|
@ -10,21 +11,21 @@ from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequ
|
|||
|
||||
class KdoItemCreate(BaseModel):
|
||||
list_id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: Decimal | None = None
|
||||
url: str | None = None
|
||||
image_url: str | None = None
|
||||
name: Title255
|
||||
description: Text10000 | None = None
|
||||
price: Amount | None = None
|
||||
url: Url2048 | None = None
|
||||
image_url: Url2048 | None = None
|
||||
is_priority: bool = False
|
||||
participation_mode: bool = False
|
||||
|
||||
|
||||
class KdoItemUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
price: Decimal | None = None
|
||||
url: str | None = None
|
||||
image_url: str | None = None
|
||||
name: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
price: Amount | None = None
|
||||
url: Url2048 | None = None
|
||||
image_url: Url2048 | None = None
|
||||
position: int | None = None
|
||||
is_priority: bool | None = None
|
||||
participation_mode: bool | None = None
|
||||
|
|
@ -32,11 +33,11 @@ class KdoItemUpdate(BaseModel):
|
|||
|
||||
class KdoItemSummary(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
price: Decimal | None
|
||||
url: str | None
|
||||
image_url: str | None = None
|
||||
name: Title255
|
||||
description: Text10000 | None
|
||||
price: Amount | None
|
||||
url: Url2048 | None
|
||||
image_url: Url2048 | None = None
|
||||
position: int
|
||||
is_priority: bool
|
||||
is_reserved: bool
|
||||
|
|
@ -49,11 +50,11 @@ class KdoItemSummary(BaseModel):
|
|||
class KdoItemResponse(BaseModel):
|
||||
id: UUID
|
||||
list_id: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
price: Decimal | None
|
||||
url: str | None
|
||||
image_url: str | None = None
|
||||
name: Title255
|
||||
description: Text10000 | None
|
||||
price: Amount | None
|
||||
url: Url2048 | None
|
||||
image_url: Url2048 | None = None
|
||||
position: int
|
||||
is_priority: bool
|
||||
participation_mode: bool
|
||||
|
|
@ -66,21 +67,21 @@ class KdoItemResponse(BaseModel):
|
|||
# ── KdoList ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ListCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title255
|
||||
description: Text10000 | None = None
|
||||
event_id: UUID | None = None
|
||||
|
||||
|
||||
class ListUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
|
||||
|
||||
class ListSummary(BaseModel):
|
||||
"""Used in GET /api/kdo/lists (dashboard)."""
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
item_count: int
|
||||
|
|
@ -91,8 +92,8 @@ class ListResponse(BaseModel):
|
|||
"""Owner detail view."""
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
items: list[KdoItemSummary]
|
||||
|
|
@ -103,8 +104,8 @@ class ListResponse(BaseModel):
|
|||
class ListPublicResponse(BaseModel):
|
||||
"""Participant view — no share_token, no reservation details."""
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
items: list[KdoItemSummary]
|
||||
|
||||
|
||||
|
|
@ -112,7 +113,7 @@ class ListPublicResponse(BaseModel):
|
|||
|
||||
class ReservationCreate(BaseModel):
|
||||
kdo_item_id: UUID
|
||||
participant_name: str
|
||||
participant_name: Name100
|
||||
participant_email: EmailStr | None = None
|
||||
reveal_to_owner: bool = False
|
||||
|
||||
|
|
@ -124,9 +125,9 @@ ReservationOut = CancelableActionOut
|
|||
|
||||
class ContributionCreate(BaseModel):
|
||||
kdo_item_id: UUID
|
||||
participant_name: str
|
||||
participant_name: Name100
|
||||
participant_email: EmailStr | None = None
|
||||
amount: Decimal
|
||||
amount: Amount
|
||||
|
||||
|
||||
ContributionOut = CancelableActionOut
|
||||
|
|
|
|||
|
|
@ -3,56 +3,57 @@ from decimal import Decimal
|
|||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from shared.validation import Amount, Name100, Text10000, Title255, Unit30, Url2048
|
||||
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
|
||||
|
||||
|
||||
# ── Requests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class KontribListCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title255
|
||||
description: Text10000 | None = None
|
||||
event_id: UUID | None = None
|
||||
auto_validate_suggestions: bool = False
|
||||
|
||||
|
||||
class KontribListUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
auto_validate_suggestions: bool | None = None
|
||||
|
||||
|
||||
class KontribItemCreate(BaseModel):
|
||||
kontrib_list_id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
url: str | None = None
|
||||
quantity: Decimal | None = None
|
||||
unit: str | None = None
|
||||
name: Title255
|
||||
description: Text10000 | None = None
|
||||
url: Url2048 | None = None
|
||||
quantity: Amount | None = None
|
||||
unit: Unit30 | None = None
|
||||
position: int = 0
|
||||
|
||||
|
||||
class KontribItemUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
url: str | None = None
|
||||
quantity: Decimal | None = None
|
||||
unit: str | None = None
|
||||
name: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
url: Url2048 | None = None
|
||||
quantity: Amount | None = None
|
||||
unit: Unit30 | None = None
|
||||
|
||||
|
||||
class KontribDeclarationCreate(BaseModel):
|
||||
item_id: UUID
|
||||
participant_name: str
|
||||
participant_name: Name100
|
||||
quantity_declared: Decimal | None = None
|
||||
|
||||
|
||||
class KontribSuggestionCreate(BaseModel):
|
||||
kontrib_list_id: UUID
|
||||
participant_name: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
quantity: Decimal | None = None
|
||||
unit: str | None = None
|
||||
participant_name: Name100
|
||||
name: Title255
|
||||
description: Text10000 | None = None
|
||||
quantity: Amount | None = None
|
||||
unit: Unit30 | None = None
|
||||
|
||||
|
||||
class SuggestionStatusUpdate(BaseModel):
|
||||
|
|
@ -63,7 +64,7 @@ class SuggestionStatusUpdate(BaseModel):
|
|||
|
||||
class DeclarationOut(BaseModel):
|
||||
id: UUID
|
||||
participant_name: str
|
||||
participant_name: Name100
|
||||
quantity_declared: Decimal | None
|
||||
cancel_token: UUID
|
||||
created_at: datetime
|
||||
|
|
@ -71,11 +72,11 @@ class DeclarationOut(BaseModel):
|
|||
|
||||
class ItemSummary(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
url: str | None
|
||||
quantity: Decimal | None
|
||||
unit: str | None
|
||||
name: Title255
|
||||
description: Text10000 | None
|
||||
url: Url2048 | None
|
||||
quantity: Amount | None
|
||||
unit: Unit30 | None
|
||||
position: int
|
||||
quantity_declared_total: Decimal | None
|
||||
declaration_count: int
|
||||
|
|
@ -88,19 +89,19 @@ class ItemDetail(ItemSummary):
|
|||
|
||||
class SuggestionOut(BaseModel):
|
||||
id: UUID
|
||||
participant_name: str
|
||||
name: str
|
||||
description: str | None
|
||||
quantity: Decimal | None
|
||||
unit: str | None
|
||||
participant_name: Name100
|
||||
name: Title255
|
||||
description: Text10000 | None
|
||||
quantity: Amount | None
|
||||
unit: Unit30 | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class KontribListSummary(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
item_count: int
|
||||
|
|
@ -111,8 +112,8 @@ class KontribListSummary(BaseModel):
|
|||
class KontribListResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
auto_validate_suggestions: bool
|
||||
|
|
@ -123,8 +124,8 @@ class KontribListResponse(BaseModel):
|
|||
|
||||
class KontribListPublicResponse(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
auto_validate_suggestions: bool
|
||||
items: list[ItemSummary]
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from api.config import settings
|
|||
from models.event import EventService
|
||||
from models.kount import KountExpense, KountExpenseSplit, KountGroup, KountGroupDefaultSplit, KountMember
|
||||
from services.kount.schemas import (
|
||||
ExpenseOut,
|
||||
ExpenseOut, PublicExpenseOut, PublicMemberOut,
|
||||
KountExpenseCreate, KountExpenseOut, KountExpenseUpdate,
|
||||
KountGroupCreate, KountGroupPublicResponse, KountGroupResponse,
|
||||
KountGroupSummary, KountGroupUpdate,
|
||||
|
|
@ -18,7 +18,7 @@ from services.kount.schemas import (
|
|||
SplitIn, SplitOut,
|
||||
)
|
||||
from services.kount.service import (
|
||||
_expense_out, _member_out, compute_balances, get_owned_group,
|
||||
_expense_out, _member_out, _public_expense_out, _public_member_out, compute_balances, get_owned_group,
|
||||
)
|
||||
from models.ownership import ResourceCoOwner
|
||||
from models.user import User
|
||||
|
|
@ -127,8 +127,8 @@ async def get_public_group(share_token: UUID, db: AsyncSession = Depends(get_db)
|
|||
title=group.title,
|
||||
description=group.description,
|
||||
currency=group.currency,
|
||||
members=[_member_out(m) for m in group.members],
|
||||
expenses=[_expense_out(e) for e in sorted(group.expenses, key=lambda e: (e.expense_date, e.created_at), reverse=True)],
|
||||
members=[_public_member_out(m) for m in group.members],
|
||||
expenses=[_public_expense_out(e) for e in sorted(group.expenses, key=lambda e: (e.expense_date, e.created_at), reverse=True)],
|
||||
balances=balances,
|
||||
settlements=settlements,
|
||||
default_splits=[SplitOut(member_id=s.member_id, share_pct=float(s.share_pct)) for s in group.default_splits],
|
||||
|
|
|
|||
|
|
@ -1,31 +1,32 @@
|
|||
from datetime import date, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, StringConstraints
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from shared.validation import Currency10, Name100, Text10000, Title255
|
||||
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
|
||||
|
||||
|
||||
# ── Requests owner ────────────────────────────────────────────────────────────
|
||||
Currency = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=10)]
|
||||
Currency = Currency10
|
||||
|
||||
|
||||
class KountGroupCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title255
|
||||
description: Text10000 | None = None
|
||||
currency: Currency = "EUR"
|
||||
event_id: UUID | None = None
|
||||
|
||||
|
||||
class KountGroupUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: Title255 | None = None
|
||||
description: Text10000 | None = None
|
||||
currency: Currency | None = None
|
||||
|
||||
|
||||
class KountMemberCreate(BaseModel):
|
||||
group_id: UUID
|
||||
name: str
|
||||
name: Name100
|
||||
email: EmailStr | None = None
|
||||
|
||||
|
||||
|
|
@ -34,55 +35,75 @@ class KountMemberCreate(BaseModel):
|
|||
class KountExpenseCreate(BaseModel):
|
||||
group_id: UUID
|
||||
paid_by_member_id: UUID
|
||||
title: str
|
||||
amount: float
|
||||
title: Title255
|
||||
amount: float = Field(gt=0, le=10_000_000)
|
||||
expense_date: date
|
||||
|
||||
|
||||
class KountExpenseUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
amount: float | None = None
|
||||
title: Title255 | None = None
|
||||
amount: float | None = Field(default=None, gt=0, le=10_000_000)
|
||||
expense_date: date | None = None
|
||||
paid_by_member_id: UUID | None = None
|
||||
|
||||
|
||||
class SplitIn(BaseModel):
|
||||
member_id: UUID
|
||||
share_pct: float
|
||||
share_pct: float = Field(gt=0, le=100)
|
||||
|
||||
|
||||
class SplitOut(BaseModel):
|
||||
member_id: UUID
|
||||
share_pct: float
|
||||
share_pct: float = Field(gt=0, le=100)
|
||||
|
||||
|
||||
# ── Responses ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
name: Name100
|
||||
email: str | None
|
||||
is_owner: bool = False
|
||||
is_co_owner: bool = False
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublicMemberOut(BaseModel):
|
||||
"""Identité strictement nécessaire à la vue partagée d’un groupe."""
|
||||
id: UUID
|
||||
name: Name100
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ExpenseOut(BaseModel):
|
||||
id: UUID
|
||||
paid_by_member_id: UUID | None
|
||||
paid_by_name: str | None
|
||||
title: str
|
||||
paid_by_name: Name100 | None
|
||||
title: Title255
|
||||
amount: float
|
||||
expense_date: date
|
||||
participant_name: str | None
|
||||
participant_name: Name100 | None
|
||||
cancel_token: UUID
|
||||
created_at: datetime
|
||||
splits: list[SplitOut] = []
|
||||
|
||||
|
||||
class PublicExpenseOut(BaseModel):
|
||||
"""Dépense visible via un lien partagé, sans jeton de gestion."""
|
||||
id: UUID
|
||||
paid_by_member_id: UUID | None
|
||||
paid_by_name: Name100 | None
|
||||
title: Title255
|
||||
amount: float
|
||||
expense_date: date
|
||||
participant_name: Name100 | None
|
||||
created_at: datetime
|
||||
splits: list[SplitOut] = []
|
||||
|
||||
|
||||
class BalanceEntry(BaseModel):
|
||||
member_id: UUID
|
||||
member_name: str
|
||||
member_name: Name100
|
||||
paid: float
|
||||
owed: float
|
||||
net: float # positif = on lui doit ; négatif = il doit
|
||||
|
|
@ -90,16 +111,16 @@ class BalanceEntry(BaseModel):
|
|||
|
||||
class Settlement(BaseModel):
|
||||
from_member_id: UUID
|
||||
from_member_name: str
|
||||
from_member_name: Name100
|
||||
to_member_id: UUID
|
||||
to_member_name: str
|
||||
to_member_name: Name100
|
||||
amount: float
|
||||
|
||||
|
||||
class KountGroupSummary(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
currency: str
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
|
|
@ -111,8 +132,8 @@ class KountGroupSummary(BaseModel):
|
|||
class KountGroupResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
currency: str
|
||||
share_token: UUID
|
||||
created_at: datetime
|
||||
|
|
@ -126,11 +147,11 @@ class KountGroupResponse(BaseModel):
|
|||
|
||||
class KountGroupPublicResponse(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title255
|
||||
description: Text10000 | None
|
||||
currency: str
|
||||
members: list[MemberOut]
|
||||
expenses: list[ExpenseOut]
|
||||
members: list[PublicMemberOut]
|
||||
expenses: list[PublicExpenseOut]
|
||||
balances: list[BalanceEntry]
|
||||
settlements: list[Settlement]
|
||||
default_splits: list[SplitOut] = []
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.kount import KountExpense, KountExpenseSplit, KountGroup, KountGroupDefaultSplit, KountMember
|
||||
from services.kount.schemas import BalanceEntry, ExpenseOut, MemberOut, Settlement, SplitOut
|
||||
from services.kount.schemas import (
|
||||
BalanceEntry, ExpenseOut, MemberOut, PublicExpenseOut, PublicMemberOut, Settlement, SplitOut,
|
||||
)
|
||||
from shared.auth.co_owner import get_owned_resource
|
||||
|
||||
|
||||
|
|
@ -38,6 +40,26 @@ def _expense_out(expense: KountExpense) -> ExpenseOut:
|
|||
)
|
||||
|
||||
|
||||
def _public_expense_out(expense: KountExpense) -> PublicExpenseOut:
|
||||
"""Sérialise une dépense pour le lien partagé sans capacité d’annulation."""
|
||||
return PublicExpenseOut(
|
||||
id=expense.id,
|
||||
paid_by_member_id=expense.paid_by_member_id,
|
||||
paid_by_name=expense.paid_by_member.name if expense.paid_by_member else None,
|
||||
title=expense.title,
|
||||
amount=expense.amount,
|
||||
expense_date=expense.expense_date,
|
||||
participant_name=expense.participant_name,
|
||||
created_at=expense.created_at,
|
||||
splits=[SplitOut(member_id=s.member_id, share_pct=float(s.share_pct)) for s in expense.splits],
|
||||
)
|
||||
|
||||
|
||||
def _public_member_out(member: KountMember) -> PublicMemberOut:
|
||||
"""Sérialise un membre pour le lien partagé sans son adresse email."""
|
||||
return PublicMemberOut(id=member.id, name=member.name, created_at=member.created_at)
|
||||
|
||||
|
||||
def _member_out(member: KountMember, owner_email: str | None = None, co_owner_emails: set[str] | None = None) -> MemberOut:
|
||||
email = member.email
|
||||
return MemberOut(
|
||||
|
|
|
|||
|
|
@ -1,44 +1,45 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from shared.validation import Name100, Text10000, Title255, Title500
|
||||
from shared.schemas import CancelableActionOut, MessageResponse, NotifyEmailRequest # noqa: F401
|
||||
|
||||
|
||||
# ── Requests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class KwizPollCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: Title500
|
||||
description: Text10000 | None = None
|
||||
event_id: UUID | None = None
|
||||
options: list[str] # 2 à 10 labels
|
||||
options: list[Title255] = Field(min_length=2, max_length=10) # 2 à 10 labels
|
||||
allow_multiple: bool = False
|
||||
results_public: bool = False
|
||||
|
||||
|
||||
class KwizPollConfigUpdate(BaseModel):
|
||||
"""Rebuild complet des options — interdit si votes existants."""
|
||||
title: str
|
||||
description: str | None = None
|
||||
options: list[str]
|
||||
title: Title500
|
||||
description: Text10000 | None = None
|
||||
options: list[Title255] = Field(min_length=2, max_length=10)
|
||||
allow_multiple: bool = False
|
||||
|
||||
|
||||
class KwizPollUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: Title500 | None = None
|
||||
description: Text10000 | None = None
|
||||
results_public: bool | None = None
|
||||
is_closed: bool | None = None
|
||||
|
||||
|
||||
class VoteSubmit(BaseModel):
|
||||
name: str
|
||||
name: Name100
|
||||
email: EmailStr | None = None
|
||||
option_ids: list[UUID] # 1 pour single, 1..N pour multiple
|
||||
option_ids: list[UUID] = Field(min_length=1, max_length=10) # 1 pour single, 1..N pour multiple
|
||||
|
||||
|
||||
class VoteUpdate(BaseModel):
|
||||
option_ids: list[UUID]
|
||||
option_ids: list[UUID] = Field(min_length=1, max_length=10)
|
||||
|
||||
|
||||
# ── Responses ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -53,14 +54,14 @@ class OptionOut(BaseModel):
|
|||
class ParticipantVoteOut(BaseModel):
|
||||
"""Votes nominatifs d'un participant (owner only ou results_public)."""
|
||||
participant_id: UUID
|
||||
name: str
|
||||
option_ids: list[UUID]
|
||||
name: Name100
|
||||
option_ids: list[UUID] = Field(min_length=1, max_length=10)
|
||||
|
||||
|
||||
class KwizPollSummary(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title500
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
allow_multiple: bool
|
||||
results_public: bool
|
||||
|
|
@ -75,8 +76,8 @@ class KwizPollSummary(BaseModel):
|
|||
class KwizPollResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title500
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
allow_multiple: bool
|
||||
results_public: bool
|
||||
|
|
@ -91,14 +92,14 @@ class KwizPollResponse(BaseModel):
|
|||
|
||||
class ParticipantPublicOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
name: Name100
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class KwizPollPublicResponse(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
title: Title500
|
||||
description: Text10000 | None
|
||||
share_token: UUID
|
||||
allow_multiple: bool
|
||||
results_public: bool
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@ from datetime import datetime
|
|||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from shared.schemas import MessageResponse # noqa: F401
|
||||
from shared.validation import ResourceType50
|
||||
|
||||
|
||||
class InviteCoOwnerRequest(BaseModel):
|
||||
resource_type: str
|
||||
resource_type: ResourceType50
|
||||
resource_id: UUID
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class CoOwnerResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
user_id: UUID
|
||||
email: str
|
||||
created_at: datetime
|
||||
|
|
@ -24,5 +25,3 @@ class AcceptInviteResponse(BaseModel):
|
|||
token_type: str = "bearer"
|
||||
resource_type: str
|
||||
resource_id: UUID
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.auth.jwt import decode_access_token
|
||||
from shared.auth.premium_check import is_premium_active
|
||||
from shared.database.connection import get_db
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
|
@ -49,6 +50,6 @@ async def get_optional_user(
|
|||
|
||||
|
||||
async def require_premium(current_user=Depends(get_current_user)):
|
||||
if not current_user.is_premium:
|
||||
if not is_premium_active(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Abonnement premium requis")
|
||||
return current_user
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
import re
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
"""Applique la politique de mot de passe commune aux parcours authentifiés."""
|
||||
errors = []
|
||||
if len(password) < 8:
|
||||
errors.append("8 caractères minimum")
|
||||
if not re.search(r"[A-Z]", password):
|
||||
errors.append("une majuscule")
|
||||
if not re.search(r"[a-z]", password):
|
||||
errors.append("une minuscule")
|
||||
if not re.search(r"\d", password):
|
||||
errors.append("un chiffre")
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
errors.append("un caractère spécial")
|
||||
if errors:
|
||||
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
|
||||
return password
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ async def enforce_premium_limit(user, db: AsyncSession) -> None:
|
|||
"""
|
||||
if is_premium_active(user):
|
||||
return
|
||||
|
||||
|
||||
# Sérialise les créations facturées du même compte : le comptage et
|
||||
# l’insertion restent dans la même transaction jusqu’au commit du routeur.
|
||||
from models.user import User
|
||||
await db.execute(select(User.id).where(User.id == user.id).with_for_update())
|
||||
|
||||
# La session passée par FastAPI conserve ce verrou jusqu’au commit.
|
||||
# Ne pas déplacer ce contrôle après db.add().
|
||||
|
||||
total = 0
|
||||
for q in _counted_service_count_queries(user.id):
|
||||
total += (await db.execute(q)).scalar() or 0
|
||||
|
|
|
|||
|
|
@ -77,3 +77,46 @@ def construct_event(payload: bytes, sig_header: str):
|
|||
async def retrieve_subscription(subscription_id: str):
|
||||
"""Récupère un objet Subscription (utilisé par certains webhooks)."""
|
||||
return await asyncio.to_thread(stripe.Subscription.retrieve, subscription_id)
|
||||
|
||||
|
||||
_ACTIVE_SUBSCRIPTION_STATUSES = {"active", "trialing", "past_due", "unpaid", "paused"}
|
||||
|
||||
|
||||
def _field(value, name: str, default=None):
|
||||
"""Lit indifféremment un objet Stripe ou un dictionnaire de test."""
|
||||
if isinstance(value, dict):
|
||||
return value.get(name, default)
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
async def cancel_active_subscriptions(
|
||||
customer_id: str | None,
|
||||
known_subscription_id: str | None = None,
|
||||
) -> int:
|
||||
"""Annule immédiatement les abonnements facturables d’un client Stripe.
|
||||
|
||||
La liste Stripe couvre le cas où le webhook n’a pas encore persisté
|
||||
``stripe_subscription_id``. Annuler une ressource déjà annulée est évité, ce
|
||||
qui rend la suppression de compte relançable sans double effet.
|
||||
"""
|
||||
subscription_ids: set[str] = set()
|
||||
if known_subscription_id:
|
||||
subscription_ids.add(known_subscription_id)
|
||||
|
||||
if customer_id:
|
||||
subscriptions = await asyncio.to_thread(
|
||||
stripe.Subscription.list, customer=customer_id, status="all", limit=100
|
||||
)
|
||||
for subscription in _field(subscriptions, "data", []):
|
||||
subscription_id = _field(subscription, "id")
|
||||
subscription_status = _field(subscription, "status")
|
||||
if subscription_id and subscription_status in _ACTIVE_SUBSCRIPTION_STATUSES:
|
||||
subscription_ids.add(subscription_id)
|
||||
|
||||
cancelled = 0
|
||||
for subscription_id in subscription_ids:
|
||||
subscription = await asyncio.to_thread(stripe.Subscription.retrieve, subscription_id)
|
||||
if _field(subscription, "status") in _ACTIVE_SUBSCRIPTION_STATUSES:
|
||||
await asyncio.to_thread(stripe.Subscription.cancel, subscription_id)
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from math import ceil
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
|
@ -10,69 +11,92 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# (max_requests, window_seconds)
|
||||
_LIMITS: dict[str, tuple[int, int]] = {
|
||||
"/api/auth/magic-link": (5, 3600), # 5/heure — anti-spam
|
||||
"/api/auth/register": (10, 3600), # 10/heure
|
||||
"/api/auth/login": (20, 3600), # 20/heure
|
||||
"/api/auth/verify-email": (20, 3600), # 20/heure — anti-brute-force code 6 chiffres
|
||||
"/api/auth/resend-verification": (5, 3600), # 5/heure — anti-spam email
|
||||
"/api/shared/ownership/invite": (10, 3600), # 10/heure — anti-spam invitations
|
||||
"/api/shared/comments": (30, 60), # 30/min
|
||||
"/api/kdo/reservations/": (10, 3600), # 10/heure — notify envoie un email
|
||||
"/api/kdo/contributions/": (10, 3600), # 10/heure
|
||||
"/api/kontrib/declarations/": (10, 3600), # 10/heure
|
||||
"/api/kount/expenses": (30, 3600), # 30/heure — dépenses + splits + cancel
|
||||
"/api/kount/members": (20, 3600), # 20/heure — ajout membres
|
||||
"/api/kwiz/public/": (30, 3600), # 30/heure — votes
|
||||
"/api/kwiz/participants/": (10, 3600), # 10/heure — notify envoie un email
|
||||
"_default": (120, 60), # 120/min global
|
||||
"/api/auth/magic-link": (5, 3600),
|
||||
"/api/auth/register": (10, 3600),
|
||||
"/api/auth/login": (20, 3600),
|
||||
"/api/auth/verify-email": (20, 3600),
|
||||
"/api/auth/resend-verification": (5, 3600),
|
||||
"/api/shared/ownership/invite": (10, 3600),
|
||||
"/api/shared/comments": (30, 60),
|
||||
"/api/kdo/reservations/": (10, 3600),
|
||||
"/api/kdo/contributions/": (10, 3600),
|
||||
"/api/kontrib/declarations/": (10, 3600),
|
||||
"/api/kount/expenses": (30, 3600),
|
||||
"/api/kount/members": (20, 3600),
|
||||
"/api/kwiz/public/": (30, 3600),
|
||||
"/api/kwiz/participants/": (10, 3600),
|
||||
"_default": (120, 60),
|
||||
}
|
||||
|
||||
_MAX_BUCKETS = 10_000
|
||||
_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"}
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiter en mémoire par IP. Single-server, reset au redémarrage."""
|
||||
"""Limiteur local borné, par IP et route logique.
|
||||
|
||||
def __init__(self, app):
|
||||
Le déploiement actuel comporte un seul processus API. Une évolution multi-réplicas
|
||||
devra remplacer ce stockage local par Redis, sans changer les clés de route.
|
||||
"""
|
||||
|
||||
def __init__(self, app, max_buckets: int = _MAX_BUCKETS):
|
||||
super().__init__(app)
|
||||
self._buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||
self._buckets: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._max_buckets = max_buckets
|
||||
|
||||
def _client_ip(self, request: Request) -> str:
|
||||
# X-Real-IP est positionné exclusivement par Caddy ({remote_host}) — fiable
|
||||
peer_ip = request.client.host if request.client else "unknown"
|
||||
# Seul Caddy local est autorisé à transmettre l'adresse du visiteur.
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
if real_ip and peer_ip in _TRUSTED_PROXY_IPS:
|
||||
return real_ip.strip()
|
||||
# Fallback : connexion directe hors Caddy (tests locaux)
|
||||
return request.client.host if request.client else "unknown"
|
||||
return peer_ip
|
||||
|
||||
def _limit_for(self, path: str) -> tuple[int, int]:
|
||||
def _limit_for(self, path: str) -> tuple[str, tuple[int, int]]:
|
||||
for prefix, limit in _LIMITS.items():
|
||||
if prefix != "_default" and path.startswith(prefix):
|
||||
return limit
|
||||
return _LIMITS["_default"]
|
||||
return prefix, limit
|
||||
return "_default", _LIMITS["_default"]
|
||||
|
||||
def _is_allowed(self, key: str, max_req: int, window: int) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(seconds=window)
|
||||
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
|
||||
if len(self._buckets[key]) >= max_req:
|
||||
return False
|
||||
self._buckets[key].append(now)
|
||||
return True
|
||||
def _check_limit(self, key: str, max_requests: int, window: int) -> tuple[bool, int]:
|
||||
now = time.monotonic()
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
if len(self._buckets) >= self._max_buckets:
|
||||
self._buckets.popitem(last=False)
|
||||
bucket = deque()
|
||||
self._buckets[key] = bucket
|
||||
else:
|
||||
self._buckets.move_to_end(key)
|
||||
|
||||
cutoff = now - window
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= max_requests:
|
||||
return False, max(1, ceil(bucket[0] + window - now))
|
||||
bucket.append(now)
|
||||
return True, 0
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
# Webhook Stripe : serveur→serveur (signature vérifiée), peut arriver en
|
||||
# rafales depuis quelques IPs Stripe — jamais rate-limité.
|
||||
# Webhook signé, reçu en rafales depuis Stripe : sa signature est le contrôle.
|
||||
if path == "/api/billing/webhook":
|
||||
return await call_next(request)
|
||||
|
||||
ip = self._client_ip(request)
|
||||
max_req, window = self._limit_for(path)
|
||||
route, (route_max, route_window) = self._limit_for(path)
|
||||
|
||||
if not self._is_allowed(f"{ip}:{path}", max_req, window):
|
||||
logger.warning("Rate limit dépassé — IP=%s path=%s", ip, path)
|
||||
# Une limite spécifique protège l'action sensible ; la limite globale borne
|
||||
# aussi tout le reste, sans créer une entrée par UUID ou jeton d'URL.
|
||||
allowed, retry_after = self._check_limit(f"{ip}:global", *_LIMITS["_default"])
|
||||
if allowed and route != "_default":
|
||||
allowed, retry_after = self._check_limit(f"{ip}:route:{route}", route_max, route_window)
|
||||
if not allowed:
|
||||
logger.warning("Rate limit dépassé — IP=%s route=%s", ip, route)
|
||||
return Response(
|
||||
content='{"detail":"Trop de requêtes. Réessayez dans un moment."}',
|
||||
status_code=429,
|
||||
media_type="application/json",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
|
|
|||
21
shared/validation.py
Normal file
21
shared/validation.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Contraintes Pydantic partagées pour les entrées HTTP.
|
||||
|
||||
Elles reflètent les limites SQL des colonnes `String` ; les champs `Text` sont
|
||||
bornés à 10 000 caractères pour éviter les charges disproportionnées.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, StringConstraints
|
||||
|
||||
Title255 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]
|
||||
Title500 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=500)]
|
||||
Name100 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
|
||||
Text10000 = Annotated[str, StringConstraints(strip_whitespace=True, max_length=10_000)]
|
||||
Url2048 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=2048)]
|
||||
Unit30 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=30)]
|
||||
EventType50 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]
|
||||
ResourceType50 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]
|
||||
Currency10 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=10)]
|
||||
Amount = Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
|
||||
SharePercent = Annotated[Decimal, Field(gt=0, le=100, max_digits=5, decimal_places=2)]
|
||||
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-security-regressions")
|
||||
115
tests/integration/test_postgres_flows.py
Normal file
115
tests/integration/test_postgres_flows.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from api.routers import auth
|
||||
from models.kount import KountExpense, KountGroup, KountMember
|
||||
from models.user import User
|
||||
from services.kount.router import get_public_group
|
||||
from services.kount.service import get_owned_group
|
||||
from shared.database.connection import AsyncSessionLocal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_rights_and_public_kount_route_against_postgres(monkeypatch):
|
||||
async def no_email(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(auth.email_sender, "send_email_verification", no_email)
|
||||
email = f"quality-{uuid4().hex}@example.com"
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
schema_check = await db.execute(text("SELECT 1"))
|
||||
assert schema_check.scalar_one() == 1
|
||||
|
||||
await auth.register(
|
||||
auth.RegisterRequest(email=email, password="Correct1!"),
|
||||
db,
|
||||
)
|
||||
user = (await db.execute(select(User).where(User.email == email))).scalar_one()
|
||||
assert user.password_hash
|
||||
assert user.email_verified is False
|
||||
|
||||
group = KountGroup(
|
||||
id=uuid4(), user_id=user.id, title="Qualité", description=None,
|
||||
currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
member = KountMember(
|
||||
id=uuid4(), group_id=group.id, name="Alice", email=email,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
expense = KountExpense(
|
||||
id=uuid4(), group_id=group.id, paid_by_member_id=member.id,
|
||||
title="Courses", amount=Decimal("12.50"), expense_date=date.today(),
|
||||
participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group.members = [member]
|
||||
group.expenses = [expense]
|
||||
expense.paid_by_member = member
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
|
||||
public_response = await get_public_group(group.share_token, db)
|
||||
public_payload = public_response.model_dump(mode="json")
|
||||
assert "email" not in str(public_payload).lower()
|
||||
assert "cancel_token" not in str(public_payload).lower()
|
||||
|
||||
stranger = User(
|
||||
id=uuid4(), email=f"stranger-{uuid4().hex}@example.com", lang="fr",
|
||||
email_verified=True, is_premium=False, cancel_at_period_end=False,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(stranger)
|
||||
await db.commit()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_owned_group(group.id, stranger.id, db)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aaa_freemium_limit_is_atomic_under_concurrent_creations():
|
||||
"""Deux créations simultanées depuis 2/3 services ne peuvent pas aboutir toutes deux."""
|
||||
import asyncio
|
||||
from services.kdo.router import create_list as create_kdo_list
|
||||
from services.kdo.schemas import ListCreate
|
||||
from services.kontrib.router import create_list as create_kontrib_list
|
||||
from services.kontrib.schemas import KontribListCreate
|
||||
|
||||
from api.config import settings
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
email = f"atomic-{uuid4().hex}@example.com"
|
||||
async with session_factory() as db:
|
||||
user = User(
|
||||
id=uuid4(), email=email, lang="fr", email_verified=True,
|
||||
is_premium=False, cancel_at_period_end=False, created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
db.add_all([
|
||||
KountGroup(id=uuid4(), user_id=user.id, title="Un", currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc)),
|
||||
KountGroup(id=uuid4(), user_id=user.id, title="Deux", currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc)),
|
||||
])
|
||||
await db.commit()
|
||||
user_id = user.id
|
||||
|
||||
async def create_kdo():
|
||||
async with session_factory() as db:
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one()
|
||||
return await create_kdo_list(ListCreate(title="Trois"), user, db)
|
||||
|
||||
async def create_kontrib():
|
||||
async with session_factory() as db:
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one()
|
||||
return await create_kontrib_list(KontribListCreate(title="Quatre"), user, db)
|
||||
|
||||
results = await asyncio.gather(create_kdo(), create_kontrib(), return_exceptions=True)
|
||||
assert sum(not isinstance(result, Exception) for result in results) == 1
|
||||
errors = [result for result in results if isinstance(result, HTTPException)]
|
||||
assert len(errors) == 1
|
||||
assert errors[0].status_code == 403
|
||||
await engine.dispose()
|
||||
166
tests/test_auth_security.py
Normal file
166
tests/test_auth_security.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.routers import auth
|
||||
from models.user import User
|
||||
from services.ownership import service as ownership_service
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.commits = 0
|
||||
self.added = []
|
||||
|
||||
async def execute(self, _query):
|
||||
return _Result(self.user)
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
def add(self, value):
|
||||
self.added.append(value)
|
||||
if isinstance(value, User):
|
||||
self.user = value
|
||||
|
||||
async def refresh(self, _value):
|
||||
return None
|
||||
|
||||
|
||||
def _user(*, verified: bool, password_hash: str | None = None) -> User:
|
||||
return User(
|
||||
id=uuid4(), email="member@example.com", lang="fr", email_verified=verified,
|
||||
password_hash=password_hash, is_premium=False, cancel_at_period_end=False, created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verified_account_never_gets_a_token_from_verify_email():
|
||||
db = _Db(_user(verified=True))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code="123456"), db)
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "email_already_verified"
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("reason", ["wrong", "expired", "reused"])
|
||||
async def test_invalid_verification_codes_never_issue_a_token(monkeypatch, reason):
|
||||
db = _Db(_user(verified=False))
|
||||
async def invalid_code(*_args):
|
||||
return False
|
||||
monkeypatch.setattr(auth, "verify_code", invalid_code)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code=reason), db)
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "invalid_or_expired_code"
|
||||
assert db.user.email_verified is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_verification_code_verifies_and_returns_a_token(monkeypatch):
|
||||
db = _Db(_user(verified=False))
|
||||
async def valid_code(*_args):
|
||||
return True
|
||||
monkeypatch.setattr(auth, "verify_code", valid_code)
|
||||
response = await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code="123456"), db)
|
||||
assert response.access_token
|
||||
assert db.user.email_verified is True
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_cannot_claim_verified_passwordless_account():
|
||||
user = _user(verified=True, password_hash=None)
|
||||
db = _Db(user)
|
||||
request = auth.RegisterRequest(email="member@example.com", password="Correct1!")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.register(request, db)
|
||||
assert exc.value.status_code == 409
|
||||
assert user.password_hash is None
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_password_requires_authenticated_user_and_shared_policy():
|
||||
user = _user(verified=True, password_hash=None)
|
||||
db = _Db(user)
|
||||
response = await auth.set_password(auth.SetPasswordRequest(password="Correct1!"), user, db)
|
||||
assert response.message
|
||||
assert user.password_hash
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_magic_link_account_cannot_be_claimed_by_registration(monkeypatch):
|
||||
user = _user(verified=True, password_hash=None)
|
||||
db = _Db(user)
|
||||
|
||||
async def valid_magic_link(_token, _db):
|
||||
return user.email
|
||||
|
||||
monkeypatch.setattr(auth, "verify_magic_token", valid_magic_link)
|
||||
response = await auth.verify_magic_link("magic-token", db=db)
|
||||
assert response.user.has_password is False
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.register(auth.RegisterRequest(email=user.email, password="Correct1!"), db)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invitation_passwordless_account_cannot_be_claimed_by_registration():
|
||||
db = _Db(None)
|
||||
invited_user, is_new = await ownership_service.get_or_create_user("invitee@example.com", db)
|
||||
assert is_new is True
|
||||
assert invited_user.email_verified is True
|
||||
assert invited_user.password_hash is None
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.register(auth.RegisterRequest(email=invited_user.email, password="Correct1!"), db)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_set_password_reuses_register_password_policy():
|
||||
with pytest.raises(ValueError):
|
||||
auth.SetPasswordRequest(password="onlyeight")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_magic_link_preserves_a_safe_internal_next(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def create_token(_email, _db):
|
||||
return "magic-token"
|
||||
|
||||
async def send_magic(_email, magic_url, lang="fr"):
|
||||
captured["url"] = magic_url
|
||||
captured["lang"] = lang
|
||||
|
||||
async def deliver(coro, _logger=None):
|
||||
await coro
|
||||
|
||||
monkeypatch.setattr(auth, "create_magic_token", create_token)
|
||||
monkeypatch.setattr(auth.email_sender, "send_magic_link", send_magic)
|
||||
monkeypatch.setattr(auth, "safe_send", deliver)
|
||||
|
||||
await auth.request_magic_link(
|
||||
auth.MagicLinkRequest(email="member@example.com", next="/hub/new?source=homepage"),
|
||||
_Db(_user(verified=True)),
|
||||
)
|
||||
|
||||
assert "next=%2Fhub%2Fnew%3Fsource%3Dhomepage" in captured["url"]
|
||||
with pytest.raises(ValueError):
|
||||
auth.MagicLinkRequest(email="member@example.com", next="//evil.example/path")
|
||||
120
tests/test_billing_deletion.py
Normal file
120
tests/test_billing_deletion.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.routers import auth, billing
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from starlette.requests import Request
|
||||
from models.user import User
|
||||
from shared.payments import stripe_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stripe_cancellation_is_idempotent_and_covers_customer_subscriptions(monkeypatch):
|
||||
calls = []
|
||||
class Subscription:
|
||||
@staticmethod
|
||||
def list(**_kwargs):
|
||||
return {"data": [{"id": "sub_active", "status": "active"}, {"id": "sub_old", "status": "canceled"}]}
|
||||
@staticmethod
|
||||
def retrieve(subscription_id):
|
||||
return {"id": subscription_id, "status": "active" if subscription_id == "sub_active" else "canceled"}
|
||||
@staticmethod
|
||||
def cancel(subscription_id):
|
||||
calls.append(subscription_id)
|
||||
async def to_thread(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
monkeypatch.setattr(stripe_client.stripe, "Subscription", Subscription)
|
||||
monkeypatch.setattr(stripe_client.asyncio, "to_thread", to_thread)
|
||||
cancelled = await stripe_client.cancel_active_subscriptions("cus_123", "sub_old")
|
||||
assert cancelled == 1
|
||||
assert calls == ["sub_active"]
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self):
|
||||
self.deleted, self.commits = [], 0
|
||||
async def delete(self, user):
|
||||
self.deleted.append(user)
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class _User:
|
||||
id = "user-1"
|
||||
stripe_customer_id = "cus_123"
|
||||
stripe_subscription_id = "sub_123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_is_deleted_after_stripe_cancellation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def cancel(customer_id, known_subscription_id):
|
||||
calls.append((customer_id, known_subscription_id))
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(auth.stripe_client, "cancel_active_subscriptions", cancel)
|
||||
db = _Db()
|
||||
user = _User()
|
||||
response = await auth.delete_account(auth.DeleteAccountRequest(confirmation="SUPPRIMER"), user, db)
|
||||
|
||||
assert response.message == "Compte et abonnement supprimés"
|
||||
assert db.deleted == [user]
|
||||
assert db.commits == 1
|
||||
assert calls == [("cus_123", "sub_123")]
|
||||
|
||||
|
||||
class _WebhookResult:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.user
|
||||
|
||||
|
||||
class _WebhookDb:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.commits = 0
|
||||
|
||||
async def execute(self, _query):
|
||||
return _WebhookResult(self.user)
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def _request():
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"{}"}
|
||||
|
||||
return Request({"type": "http", "method": "POST", "path": "/webhook", "headers": []}, receive)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscription_deleted_webhook_removes_premium_access(monkeypatch):
|
||||
user = User(id=uuid4(), email="stripe.com", lang="fr", email_verified=True, is_premium=True, cancel_at_period_end=True, created_at=datetime.now(timezone.utc), stripe_customer_id="cus_123")
|
||||
db = _WebhookDb(user)
|
||||
monkeypatch.setattr(billing.stripe_client, "construct_event", lambda *_args: {"type": "customer.subscription.deleted", "data": {"object": {"customer": "cus_123"}}})
|
||||
|
||||
result = await billing.webhook(_request(), db)
|
||||
|
||||
assert result == {"received": True}
|
||||
assert user.is_premium is False
|
||||
assert user.subscription_status == "canceled"
|
||||
assert user.cancel_at_period_end is False
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_is_not_deleted_when_stripe_cancellation_fails(monkeypatch):
|
||||
async def fail(*_args, **_kwargs):
|
||||
raise RuntimeError("Stripe unavailable")
|
||||
monkeypatch.setattr(auth.stripe_client, "cancel_active_subscriptions", fail)
|
||||
db = _Db()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.delete_account(auth.DeleteAccountRequest(confirmation="SUPPRIMER"), _User(), db)
|
||||
assert exc.value.status_code == 502
|
||||
assert db.deleted == []
|
||||
assert db.commits == 0
|
||||
11
tests/test_docker_context.py
Normal file
11
tests/test_docker_context.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
def test_docker_context_excludes_secrets_and_bulk_dependencies():
|
||||
ignored = set(Path(".dockerignore").read_text().splitlines())
|
||||
assert {".env", ".git", "frontend/node_modules/", "__pycache__/"}.issubset(ignored)
|
||||
|
||||
|
||||
def test_frontend_docker_context_excludes_node_modules():
|
||||
ignored = set(Path("frontend/.dockerignore").read_text().splitlines())
|
||||
assert "node_modules/" in ignored
|
||||
47
tests/test_g1_security.py
Normal file
47
tests/test_g1_security.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.main import app
|
||||
from api.routers.scrape import ParseRequest, _MAX_HTML_BYTES, _ensure_payload_size
|
||||
from shared.rate_limit.middleware import RateLimitMiddleware
|
||||
|
||||
|
||||
def test_rate_limit_uses_normalized_routes_and_bounded_storage():
|
||||
limiter = RateLimitMiddleware(FastAPI(), max_buckets=2)
|
||||
|
||||
route, _ = limiter._limit_for("/api/kount/expenses/8f86e6fa-82b0-4f4f-a5da-6d734a7a4e82")
|
||||
assert route == "/api/kount/expenses"
|
||||
assert limiter._limit_for("/api/hub/any-id")[0] == "_default"
|
||||
|
||||
assert limiter._check_limit("one", 2, 60) == (True, 0)
|
||||
assert limiter._check_limit("two", 2, 60) == (True, 0)
|
||||
assert limiter._check_limit("three", 2, 60) == (True, 0)
|
||||
assert list(limiter._buckets) == ["two", "three"]
|
||||
|
||||
|
||||
def test_rate_limit_returns_retry_after_when_bucket_is_full():
|
||||
limiter = RateLimitMiddleware(FastAPI())
|
||||
assert limiter._check_limit("visitor:global", 1, 60) == (True, 0)
|
||||
allowed, retry_after = limiter._check_limit("visitor:global", 1, 60)
|
||||
assert allowed is False
|
||||
assert 1 <= retry_after <= 60
|
||||
|
||||
|
||||
def test_scraper_rejects_oversized_html_before_parsing():
|
||||
assert _ensure_payload_size("x" * (_MAX_HTML_BYTES + 1)) is None
|
||||
try:
|
||||
ParseRequest(url="https://example.org", html="x" * (_MAX_HTML_BYTES + 1))
|
||||
except ValidationError as exc:
|
||||
assert "string_too_long" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Le modèle doit refuser le HTML trop long")
|
||||
|
||||
|
||||
def test_api_security_headers_are_set_on_health():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"
|
||||
31
tests/test_input_validation.py
Normal file
31
tests/test_input_validation.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from services.comments.schemas import CommentCreate
|
||||
from services.hub.schemas import EventCreate
|
||||
from services.kdo.schemas import ContributionCreate, KdoItemCreate, ListCreate
|
||||
from services.kontrib.schemas import KontribItemCreate
|
||||
from services.kount.schemas import KountExpenseCreate, KountGroupCreate
|
||||
from services.kwiz.schemas import KwizPollCreate
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "payload"),
|
||||
[
|
||||
(ListCreate, {"title": "x" * 256}),
|
||||
(KdoItemCreate, {"list_id": uuid4(), "name": "x" * 256}),
|
||||
(ContributionCreate, {"kdo_item_id": uuid4(), "participant_name": "A", "amount": "0"}),
|
||||
(KontribItemCreate, {"kontrib_list_id": uuid4(), "name": "A", "url": "x" * 2049}),
|
||||
(KountGroupCreate, {"title": "A", "currency": "x" * 11}),
|
||||
(KountExpenseCreate, {"group_id": uuid4(), "paid_by_member_id": uuid4(), "title": "A", "amount": 0, "expense_date": date.today()}),
|
||||
(EventCreate, {"title": "A", "event_type": "x" * 51}),
|
||||
(KwizPollCreate, {"title": "A", "options": ["one"]}),
|
||||
(CommentCreate, {"author_name": " ", "content": "hello"}),
|
||||
],
|
||||
)
|
||||
def test_invalid_api_input_is_rejected_before_database(factory, payload):
|
||||
with pytest.raises(ValidationError):
|
||||
factory(**payload)
|
||||
58
tests/test_kount_public_contract.py
Normal file
58
tests/test_kount_public_contract.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from shared.database.connection import get_db
|
||||
from services.kount.router import router as kount_router
|
||||
|
||||
from models.kount import KountExpense, KountMember
|
||||
from services.kount.service import _expense_out, _public_expense_out, _public_member_out
|
||||
|
||||
|
||||
def test_public_kount_payload_never_contains_email_or_cancel_token():
|
||||
member = KountMember(id=uuid4(), group_id=uuid4(), name="Alice", email="alice@example.com", created_at=datetime.now(timezone.utc))
|
||||
expense = KountExpense(id=uuid4(), group_id=member.group_id, paid_by_member_id=member.id, paid_by_member=member, title="Courses", amount=Decimal("12.50"), expense_date=date.today(), participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc), splits=[])
|
||||
public_payload = {"members": [_public_member_out(member).model_dump(mode="json")], "expenses": [_public_expense_out(expense).model_dump(mode="json")]}
|
||||
private_expense = _expense_out(expense)
|
||||
serialized = str(public_payload).lower()
|
||||
assert "alice@example.com" not in serialized
|
||||
assert "cancel_token" not in serialized
|
||||
assert private_expense.cancel_token == expense.cancel_token
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, group):
|
||||
self.group = group
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.group
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, group):
|
||||
self.group = group
|
||||
|
||||
async def execute(self, _query):
|
||||
return _Result(self.group)
|
||||
|
||||
|
||||
def test_public_kount_http_contract_hides_private_fields():
|
||||
member = KountMember(id=uuid4(), group_id=uuid4(), name="Alice", email="alice.com", created_at=datetime.now(timezone.utc))
|
||||
expense = KountExpense(id=uuid4(), group_id=member.group_id, paid_by_member_id=member.id, paid_by_member=member, title="Courses", amount=Decimal("12.50"), expense_date=date.today(), participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc), splits=[])
|
||||
from models.kount import KountGroup
|
||||
group = KountGroup(id=member.group_id, user_id=uuid4(), title="Week-end", description=None, currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc), members=[member], expenses=[expense], default_splits=[])
|
||||
db = _Db(group)
|
||||
app = FastAPI()
|
||||
app.include_router(kount_router, prefix="/api/kount")
|
||||
|
||||
async def override_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
response = TestClient(app).get(f"/api/kount/groups/shared/{group.share_token}")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "email" not in str(payload).lower()
|
||||
assert "cancel_token" not in str(payload).lower()
|
||||
56
tests/test_premium_effective.py
Normal file
56
tests/test_premium_effective.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.routers.auth import _user_response
|
||||
from api.routers.billing import CheckoutRequest, create_checkout
|
||||
from models.user import User
|
||||
from shared.auth.dependencies import require_premium
|
||||
from shared.auth import premium_check
|
||||
|
||||
|
||||
def make_user(*, premium_until: datetime | None, is_premium: bool = True) -> User:
|
||||
return User(
|
||||
id=uuid4(),
|
||||
email=f"premium-{uuid4().hex}@example.com",
|
||||
lang="fr",
|
||||
email_verified=True,
|
||||
is_premium=is_premium,
|
||||
premium_until=premium_until,
|
||||
cancel_at_period_end=False,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_expired_premium_is_false_in_api_user_response():
|
||||
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
|
||||
assert premium_check.is_premium_active(user) is False
|
||||
assert _user_response(user).is_premium is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_premium_cannot_access_premium_route():
|
||||
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await require_premium(current_user=user)
|
||||
|
||||
assert error.value.status_code == 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_premium_can_start_a_new_checkout(monkeypatch):
|
||||
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
|
||||
async def create_session(*_args, **_kwargs):
|
||||
return "checkout_secret"
|
||||
|
||||
monkeypatch.setattr("api.routers.billing.stripe_client.PRICE_BY_PLAN", {"monthly": "price_test"})
|
||||
monkeypatch.setattr("api.routers.billing.stripe_client.create_checkout_session", create_session)
|
||||
|
||||
response = await create_checkout(CheckoutRequest(plan="monthly"), current_user=user, db=object())
|
||||
|
||||
assert response.client_secret == "checkout_secret"
|
||||
Loading…
Add table
Reference in a new issue