Audit complet : 2 incohérences fond/texte critiques sur fond CLAIR `bg-brand-bg`
(#f7f9fc) où des conteneurs `bg-white` à faible opacité rendaient le texte
quasi-invisible.
Fixes :
1. Bottom feature info card (sous le phone) — INVISIBLE
`bg-white/[0.06] border-white/[0.10]` sur section claire = blanc 6%/10%
sur near-white = card et bordure invisibles. Texte intérieur `text-white`
et `text-white/65` aussi invisibles.
→ `bg-brand-navy` SOLIDE + `border-white/10` (extension visuelle naturelle
du phone shell). Texte blanc maintenant lisible avec contraste WCAG AA.
2. Right panel feature grid 6 boutons — État ACTIF invisible
Quand actif, fond `${color}14` (8% opacité du color sur section claire)
= très light tint. Label `rgba(255,255,255,0.95)` sur fond clair tinté
= quasi-invisible. Idem pour inactif text à 0.65 — bordure subtile.
→ Fond TOUJOURS dark `rgba(8,12,24,0.85-0.95)` (actif/inactif), avec
différenciation via border + glow + scale + drop-shadow du color brand.
Label porté à 0.98/0.70 pour AA garanti.
Test adapté : assertion `bg-white/[0.06]` remplacée par
`dictia-feature-card rounded-xl px-4 py-3 relative bg-brand-navy`.
29/29 tests pertinents passent ; 5 échecs pré-existants sur /conformite et
/landing sans rapport avec /fonctionnalites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le perception "le contour change" venait de 4 propriétés tintées par activeColor
sur le phone-shell : border (${activeColor}40), box-shadow (${activeColor}30),
ambient overlay (${activeColor}08), external glow ring (${activeColor}28).
À chaque switch de mode, ces 4 valeurs s'animaient en couleur → halo qui morphe
visuellement même si width/height restent fixes.
Fix : verrouille border/shadow/glow ring/ambient à des valeurs STATIQUES
(white/[0.10] border, brand-b1 0.20 shadow halo, white/[0.015] ambient,
brand-b1 0.18 glow ring) — comme un vrai téléphone, le bezel ne change
JAMAIS de couleur. Seul le contenu interne (modes, feature info card,
boutons feature) reste tinté par activeColor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remplace la section "Comment ça marche" (réacteur orbital générique du commit
03f6e56) par une reproduction fidèle du composant DashboardHolographique
défini dans Website-Sanity/components/sections/dictai-narrative.tsx.
Architecture : container "phone" central (border-radius 44px, color tinting
selon feature active) + 6 modes uniques (Transcription upload+words,
Diarisation conversation Sophie/Marc/Julie, 99+ langues grille staggered,
Exports 7 file icons, Users avatars 1→20, Share folders+tags+files) +
IA Mistral 7B premium card + grid 3 cols × 6 features cliquables.
Auto-cycle 900 ms (1→6→1, skip IA index 0) avec click manuel → isManual
pendant 4500 ms puis reprise auto. Animations Framer Motion → CSS
keyframes + Alpine setInterval (preserves prefers-reduced-motion guard,
aria-live, aria-pressed).
Couleurs source spécifiques préservées (#A78BFA #22D3EE #6B9FFF #34D399
#F59E0B) — identifient les features et restent indépendantes de la palette
brand globale b1/b2/b3.
Test test_fonctionnalites_how_it_works_reactor_section adapté à la nouvelle
structure (dictiaDashboard, 5 sub-data fns, 6 modes par signature unique,
IA premium card animations, auto-cycle 900ms / 4500ms manual reset).
Ajoute une nouvelle section interactive sous les 6 fonctionnalités
(préservées intégralement) reproduisant le composant React
dictai-narrative.tsx en CSS pur + Alpine.js — sans Framer Motion ni
autre lib JS.
- Réacteur central holographique : 3 anneaux concentriques rotatifs
(15 s / 22 s / 30 s) + 8 particules orbitales (cyan/blue/fuchsia)
+ wordmark DictIA glow pulsant
- Auto-cycle Alpine.js entre 6 features (1.6 s) avec pause au
hover/focus et reprise au leave/blur
- Panneau feature active avec aria-live='polite' pour annonce
lecteur d'écran (Transcription · Diarisation · 99+ langues ·
Exports · Utilisateurs illimités · Partage & Classement)
- Card 'IA intégrée Mistral 7B LOCAL' avec 3 bullets souveraineté
- Spec list cliquable / hover déclenchant feature dans réacteur
- Layout responsive grid 2 cols desktop, stack mobile
- prefers-reduced-motion désactive rings + orbites + auto-cycle
- Position : APRÈS '6 fonctionnalités', AVANT 'Intégrations'
- Sub-nav reste à 4 ancres (sous-partie visuelle de Fonctionnalités)
- Tests : nouveau test_fonctionnalites_how_it_works_reactor_section
valide structure, contenu canonique, a11y et Alpine bindings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure _pricing_tiers.html : les 3 forfaits Cloud (Basic 189$/Essentiel 349$/Pro 549$ recommandé) sont maintenant en grid responsive 1/2/3 cols, et DictIA LOCAL est sorti de la grille principale pour devenir un bloc large dédié 'propriété' avec :
- badge 'Au Québec · par InnovA AI' (SVG map-pin, sans emoji 🇨🇦)
- H3 'Vous en êtes propriétaire.' avec grad-text
- 5 bullets checkmark (PC+GPU RTX, 100 % local, assemblé QC, installation incluse, achat direct < 34 700 $)
- CTA 'Voir les serveurs disponibles' → /contact?plan=dictia-local
- mockup serveur à droite (SVG rack + 6 specs : Interface web, PC gaming, RTX 5070 Ti 16 Go, WhisperX+Mistral, DictIA pré-installé, Votre propriété)
- pricing tagline visible '5 998 $ An 1 · 500 $/an dès An 2'
- decorative orbs background (b1 + b3) pour distinguer du grid Cloud
Aussi mis à jour /tarifs (H1 'Trois forfaits Cloud + DictIA LOCAL' au lieu de 'Quatre forfaits') et tests pour refléter le nouveau slug /contact?plan=dictia-local (au lieu du /checkout/dictia-local d'avant). Conserve V3 radii (rounded-none/rounded/rounded-full), palette brand (b1/b2/b3/navy), OQLF NBSP, ARIA WCAG, zéro emoji.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refonte de la section hero: passage d'un layout centré single-col
à un grid 2 colonnes (texte gauche + mockup app à droite) sur lg+,
avec préservation du centrage actuel sur mobile/tablette.
- Mockup ~560×500px reproduit l'interface DictIA réelle:
- Window chrome (3 dots traffic light + tab "DictIA — Enquêter")
- Sidebar: 6 enregistrements groupés (Semaine dernière 2 + Mois
dernier 4) avec chips colorés (En cours, Barreau Confidentiel,
CPA Corporatif, Urgent Client) + bouton + recording rounded-none
- Center: header card avec 4 metas (avatars/calendar/clock/file),
audio player avec progress bar animée 50%-75% (15s loop),
transcript 5 lignes speaker (Allison/SPEAKER_02) où la ligne
active cycle toutes les 2.8s via Alpine x-data idx
- Right: tabs Résumé/Notes/Discuter (Résumé actif), résumé
exemple + 4 points clés
- Tilt subtil rotate-1 → straighten + scale au hover (lg only)
- 2 glow orbs flottants décoratifs derrière (bg-brand-b1/15 +
bg-brand-b3/10) avec tc-float-y reverse
- role="img" + aria-label descriptif sur le mockup complet
- prefers-reduced-motion désactive toutes animations + freeze
progress bar à 60% + retire transform tilt
- Tous les éléments interactifs ont tabindex="-1" + aria-hidden
car purement décoratifs (pas de duplication d'app réelle)
- Aucun emoji (SVG inline stroke="currentColor" partout)
- Système border-radius respecté: rounded-none (boutons/inputs/
tuiles), rounded (4px wrapper card), rounded-full (chips/avatar)
Tests: 6/6 hero tests pass (eyebrow, h1+grad-text, dual CTA,
cosmic orbs, social proof, animations staggered). Les 3 fails
restants (test_landing_has_main_nav, test_footer_links_complete,
test_trust_bar_has_eyebrow_factual_phrasing) sont préexistants
et sans rapport avec la refonte hero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Solution pillars (3 cards) : retirer le bloc icône — ne garder que h3 + p
- Bento macro : supprimer la tuile grad-bg, rendre l'icône directement en text-brand-b1, watermark passe à grad-text opacity-20 (famille bleu marque, plus visible que white/[0.04])
- Conformité forteresse (4 cards) : supprimer la tuile grad-bg, rendre l'icône en text-brand-b1
- Bumper toutes les icônes bento (landing + fonctionnalites + default macro) et conformité de w-5 h-5 → w-7 h-7 maintenant qu'elles n'ont plus de backdrop
- Mettre à jour test_bento_uses_flexihub_styling pour refléter la nouvelle structure (grad-text opacity-20 + text-brand-b1 mb-4 au lieu de white/[0.04] + grad-bg rounded-none)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit conducted 2026-04-27 against signed PDFs in DOCS_DictIA/. All 6 legal
markdown files + 3 marketing templates aligned on the contractual ground truth
(documents signed by Allison Rioux + Jean-David Lévesque-Rioux 9 mars 2026).
CRITICAL DISCREPANCIES FIXED (D1-D9 — Loi 25 / contractual)
D1. Entity identity: removed false "filiale d'InnovA AI S.E.N.C." claim.
Canonical (PDC §1.1, CGU §1, RPRP doc): DictIA Inc. is a standalone SPA
constituted 22 mars 2026 (LSAQ), 50/50 owned by Allison Rioux + Jean-David
Lévesque-Rioux. NOT a subsidiary of InnovA AI.
D2. NEQ: replaced placeholder with canonical NEQ 1181949562 (DictIA Inc.).
D3. Sub-processors list: PDC §6.2 declares 5 sub-processors. Site listed only
OVH, Stripe, Resend (the latter two not in canonical). Now aligned:
OVH Beauharnois QC + GCP Toronto ON (RAM-only, 5min) + Cloudflare US (CDN)
+ HubSpot US (CRM) + Stripe US (paiements). Resend removed.
D4. GCP Toronto disclosure: NEW. PDC §6.2, §11.2, EFVP_GCP all declare GPU
processing on GCP Toronto Ontario as a transfer hors-Québec under art. 17
LSP. Site previously claimed "100 % au Québec" without GCP disclosure.
Now declared in confidentialite.md §6, §7 + conditions.md §2.4, §9 +
conformite.html pillar.
D5. Biometrics: NEW dedicated section. PDC §12, CGU §6, EFVP_BIOVOCAL all
require disclosure of voice biometrics (pyannote.audio embeddings) per
LCCJTI art. 44-45 + CAI declaration K1. Site had ZERO mention. Now
documented in confidentialite.md §12 + conditions.md §8.
D6. Wrong article number: landing.html cited "art. 60.1 LPRPSP" for biometric
sanctions — that article does NOT exist. Replaced with canonical citation:
"art. 44-45 LCCJTI + art. 12 LSP".
D7. Speakr fork attribution: CGU §13.1.1 explicitly requires the AGPL §13
disclosure URL to be gitea.dictia.ca (not gitea.innova-ai.ca). Mentions.md
+ conformite.html + footer normalized.
D8. Conservation periods: aligned to canonical CGU §8.1.2 + PDC §7.2.
Audio: 30 jours par défaut (extensible 12 mois opt-in) — was "indéfinie".
Biométrie inter-sessions: max 12 mois — était absent.
Facturation: 7 ans — était "6 ans".
Sauvegardes: 30 jours OVH QC.
D9. RPRP contact: confirmed canonical rprp@dictia.ca (per PDC §1.2 + RPRP
designation §1.3) — was already correct on site, kept as-is.
MEDIUM (M1-M3)
M1. Cookies categories: aligned to PDC §5.1 (5 categories: essentiels +
Cloudflare + perf + fonctionnels + HubSpot). Removed "Plausible Analytics
auto-hébergé" claim (not in any signed doc).
M2. DPA status: noted as "signed" for OVH + HubSpot (signed PDFs verified),
"in vigueur" for Stripe.
M3. Footer mentions légales link: added (was missing).
MINOR (N1-N2)
N1. Stripe entity: "Stripe Inc., San Francisco CA" (canonical PDC §2.6),
not "Stripe Payments Canada Ltd." (which doesn't appear in any signed doc).
N2. Engagement de non-entraînement IA: added to conditions.md §10 (canonical
CGU §10).
NOT MODIFIED (per scope boundaries)
- src/api/auth.py, src/billing/*.py, src/models/*.py — code not touched.
- templates/marketing/{tarifs,fonctionnalites}.html — frontend A-2.x final.
- landing.html — only minimal art. 60.1 → art. 44-45 fix (factual law error).
PENDING ALLISON REVIEW
- landing.html line 167-174 marketing claim "Vos données ne sortent jamais
de vos murs ou nos serveurs OVH Beauharnois" is technically inaccurate for
DictIA Cloud users (audio briefly transits to GCP Toronto for GPU processing,
RAM-only, 5min, zero persistence — encadré par EFVP signée). Decision
required: rephrase OR add asterisk pointing to /conformite for Cloud
architecture caveat.
- CAI form (CAI_FO_Declaration_Biometrie_DictIA_COMPLET_signé.pdf) declares
90 jours retention for inter-sessions vectors, while PDC + CGU + EFVP
all say 12 mois. Site uses 12 mois (latest, contractual). Allison should
verify CAI form needs amendment before submission.
TESTS
9/9 test_legal_pages.py passing (added biometrics + decisions automatisees
to required_topics; corrected "transfert hors-québec" → "transferts hors
québec" to match canonical PDC §11 OQLF wording).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/legal/__init__.py: define canonical LEGAL_VERSION='2026-04-27' constant
(single source of truth — auth.py now imports it as SIGNUP_LEGAL_VERSION).
- src/legal/routes.py: add /legal/<page> + /legal/ index routes; markdown rendered
from src/legal/content/*.md with toc, tables, fenced_code, attr_list extensions.
- src/legal/content/: 6 French (Québec) markdown documents — DictIA Inc. /
InnovA AI S.E.N.C. branding, Loi 25-compliant 12-section privacy policy,
WCAG 2.2 AA accessibility statement, AGPL-3.0 attribution. All marked
DRAFT v1.0 pending legal review by Allison Rioux.
- templates/legal/_layout.html + index.html: extends marketing/base.html;
inline .legal-content typographic styles (no CSS rebuild required).
- .gitignore: allow-rule for src/legal/content/*.md so markdown is tracked
despite the global *.md ignore.
- tests/test_legal_pages.py: 9 tests covering 200 responses, DictIA branding,
rprp@dictia.ca presence, 12 mandatory Loi 25 sections, public indexability
(no X-Robots-Tag noindex), shared layout, marketing/base.html extension,
DRAFT callout, and LEGAL_VERSION/SIGNUP_LEGAL_VERSION equivalence.
- tests/_run_legal_pages_windows.py: manual driver (Windows fcntl stub).
- static/css/marketing.css: regenerated by `npm run build:css` to include
new utility classes referenced from templates/legal/*.html.
Tests: 9/9 pass. No off-limits files modified beyond the 2-line auth.py
constant move spec'd in B-2.9. No schema changes; markdown==3.5.1 already
pinned in requirements.txt (B-1.1). Pages publicly indexable by design
(Loi 25 transparency).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the customer-facing checkout flow under /checkout/<plan>:
- src/billing/plans.py — Plan dataclass + 3 plans (DictIA 8 / 16 / Cloud),
monthly + yearly Price IDs resolved from STRIPE_DICTIA_*_{SETUP,MONTHLY,YEARLY} env.
- src/billing/stripe_client.py — lazy stripe.api_key init, get_or_create_customer
(persists user.stripe_customer_id), create_checkout_session with mode=subscription,
currency=cad, automatic_tax=true (TPS 5% + TVQ 9.975%), billing_address_collection,
metadata on both Session and Subscription for the B-2.8 webhook.
- src/billing/routes.py — GET /checkout/<plan>?period=monthly|yearly returns 303
redirect to Stripe-hosted Checkout. Friendly French flash + redirect to /tarifs
on unknown plan, missing STRIPE_SECRET_KEY, missing Price IDs, or Stripe API error.
GET /checkout/success and /checkout/cancel render brand-tokenized templates that
extend marketing/base.html.
- templates/billing/{success,cancel}.html — explicit "activé sous quelques minutes"
note (webhook is async), aucun montant prélevé reassurance on cancel.
- config/env.stripe.example — env vars + Stripe Dashboard setup checklist
(CAD activation, Stripe Tax registrations, Apple/Google Pay enable, webhook).
- tests/test_stripe_checkout.py — 25 tests covering plans, stripe_client, routes,
and the _PUBLIC_INDEXABLE_ENDPOINTS integration. Stripe SDK mocked via
unittest.mock.patch (no network). Windows manual driver included.
Webhook (B-2.8) will be the source of truth for user.subscription_status.
This task only mutates user.stripe_customer_id (identity, not state).
Existing pricing CTAs in templates/marketing/_partials/_pricing_tiers.html
already link to /checkout/<slug> (verified) — no marketing template touched.
Tests: 25/25 new + 89/89 prior pass on Windows manual driver.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds phishing-resistant 2nd factor via FIDO2 hardware keys (YubiKey etc.)
and device biometrics (Touch ID, Windows Hello, etc.). Reuses the existing
B-2.5 TOTP gate so a passkey is a 3rd valid option on /2fa/verify, alongside
TOTP code and recovery code. Post-login enrolment lives at /2fa/passkey/setup.
Wraps python-webauthn==2.5.2 in a thin service layer (src/auth/webauthn.py)
that persists credentials in the existing User.webauthn_credentials JSON
column (added in B-2.1 — no schema change). Each credential dict carries
id, public_key, sign_count, transports, name, and created_at. sign_count is
updated after every successful authentication for WebAuthn anti-cloning
(§6.1.1).
Backend: 6 new auth routes (passkey_setup, register/begin, register/finish,
delete, auth/begin, auth/finish). The 4 JSON endpoints are CSRF-exempt at
Flask-WTF level because CSRFProtect cannot read tokens from a JSON body
without app-wide config; the X-CSRFToken header is still sent as
defence-in-depth. The form-POST delete route DOES enforce CSRF. The
@csrf_exempt decorator was previously a no-op label; init_auth_extensions
now walks module-level functions and applies real csrf.exempt() to any
flagged with _csrf_exempt=True.
Login gate now fires when the user has TOTP enabled OR at least one
passkey, and totp_verify_login passes has_passkeys + has_totp flags so the
template can show only the relevant sections.
Frontend: templates/auth/totp_verify.html updated IN PLACE with a passkey
button section (above TOTP) and an "ou" divider. New
templates/auth/passkey_setup.html for managing/enrolling passkeys. New
static/js/webauthn-client.js (no external deps, ES2020) wraps
navigator.credentials and exchanges base64url payloads with the backend.
Tailwind CSS rebuilt.
Tests: 22 new tests in tests/test_webauthn_passkey.py covering the service
layer (b64url helpers, RP config, list/has, begin/finish for both
registration and authentication, delete) and the route flow (CSRF-exempt
JSON endpoints, login gate redirection, sign_count anti-cloning
persistence). Mocks python-webauthn's verify_* functions so tests run
without a real authenticator. Windows manual driver follows the existing
no-conftest pattern.
Self-review: 22/22 new tests pass; 21/21 prior TOTP, 16/16 email,
21/21 OAuth tests still pass (no regression).
Env: config/env.oauth.example documents WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME,
WEBAUTHN_ORIGIN with full deployment notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TOTP-based two-factor authentication (RFC 6238) with 10 single-use
recovery codes. Secret is encrypted at rest with a Fernet key derived
deterministically from app SECRET_KEY (SHA-256 -> urlsafe-base64); the raw
base32 secret never lives in the database. Recovery codes are bcrypt-hashed
and consumed atomically (single-use, removed from the JSON list on match).
Routes:
- GET /2fa/setup: generate fresh secret + QR + 10 recovery codes; cache
pending state in session, render auth/totp_setup.html with inline QR
data URL and the 10 codes shown ONCE.
- POST /2fa/setup: verify the user-submitted 6-digit code against the
pending secret; on success persist encrypted secret + hashes and flip
totp_enabled=True. On invalid code re-render same QR (don't rotate),
preserving the user's authenticator scan.
- GET /2fa/verify: second factor during login; reads pending_totp_user_id
from session and renders auth/totp_verify.html (TOTP code input +
collapsed recovery code form, with X codes restants notice).
- POST /2fa/verify: accepts EITHER a 6-digit TOTP code OR a recovery code;
on success finalises login_user (preserving remember-me intent + next
URL captured at the password step), audits success/failure.
- POST /2fa/disable: requires password re-auth; nullifies the 3 TOTP fields.
Login gate (src/api/auth.py /login): after password+email-verification
checks but BEFORE login_user, if user.totp_enabled set
session['pending_totp_user_id'] / pending_totp_remember /
pending_totp_next and 302 -> /2fa/verify. OAuth/SSO/magic-link paths are
intentionally NOT gated in B-2.5 (deferred — IdP handles its own MFA).
Schema:
- New JSON column User.totp_recovery_codes (nullable) added via
add_column_if_not_exists in src/init_db.py (no Alembic, follows existing
pattern).
- Re-uses B-2.1 columns totp_secret_encrypted (VARCHAR 255) and
totp_enabled (BOOLEAN); both already migrated.
Compatibility audit overrides honoured:
- Service layer at src/auth/totp.py (NOT a new src/auth_extended/ pkg).
- Templates at templates/auth/totp_setup.html and templates/auth/totp_verify.html
extending marketing/base.html with brand tokens + WCAG patterns
(focus-visible, role=alert, aria-required, autocomplete=one-time-code,
inputmode=numeric).
- account.html integration deferred to a polish task — admins access
/2fa/setup directly for now.
Tests (21, all green via Windows manual driver):
- Service layer: encrypt/decrypt round-trip, key-mismatch rejection, secret
validity, code verification (current/wrong/non-digit), recovery codes
(10 pairs, 1:1 bcrypt mapping, single-use consumption, unknown rejection),
set/disable user TOTP fields.
- Routes: login redirect-to-/2fa/verify when totp_enabled, direct login
when disabled, /2fa/verify with correct/wrong TOTP, recovery code consume,
redirect-to-login when no pending session, /2fa/setup GET creates pending,
POST with valid code enables MFA, POST with invalid code keeps pending +
returns 400, /2fa/disable wrong/correct password.
Regression check: prior 21 OAuth+magic-link, 16 email-service, and 9
signup-Loi-25 tests all still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Targeted fixes for issues raised by code review on commit 37639a7
(B-2.3 DictIA email rebrand). All fixes verified against the Windows
manual driver: 16/16 tests pass (12 pre-existing + 4 new regression).
Critical:
- C1 Stored XSS in transactional emails: user.name (validated only on
Length(max=49), no character class) was rendered raw into the f-string
HTML body of verification + reset emails. Added html.escape on the
HTML branch; text body keeps the raw string (no XSS surface). Also
hardened the fallback chain to ((name or '').strip() or username or
'utilisateur').strip() so a None/whitespace name never produces
'Bonjour ,'.
- C2 Reflected XSS in templates/auth/check_email.html: the email value
from request.form was concatenated with literal '<strong>' tags then
fed through | safe, defeating Jinja's autoescape. Split the string so
template-author HTML stays literal and {{ email }} is autoescaped.
Used   for NBSP instead of '1 heure' | safe (more readable).
Important:
- I1 Dropped {{ message | safe }} on flash blocks in
forgot_password.html and reset_password.html (matches check_email.html).
No XSS today (flashes are static literals) but removes the landmine.
- I2 Password reset token replay: URLSafeTimedSerializer is stateless,
so the same valid link could be clicked twice within the 1h window.
Added a check that user.password_reset_token == token after the user
lookup — runs before BOTH GET (form render) and POST (password update).
The existing 'user.password_reset_token = None' on success now
actually invalidates the token.
- I5 MIMEText defaults to us-ascii, which Q-encodes accented French
characters and produces mojibake in some clients. Added explicit
'utf-8' charset on both text and html parts in _send_email.
New regression tests (tests/test_email_service_dictia.py):
- test_verification_email_falls_back_when_name_is_whitespace (I4)
- test_verification_email_handles_unicode_name (I5)
- test_verification_email_escapes_html_in_user_name (C1)
- test_check_email_template_escapes_email_in_response (C2)
Out of scope (per review): M1 (already addressed via solid-color
fallback), M2 (datetime.utcnow — pre-existing, separate cleanup),
M3 (Windows test driver — documented in tests file docstring),
M4-M6 (deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebrand src/services/email.py IN PLACE: French + DictIA + brand gradient
(#0062ff/#00bdd8/#00c896) — replaces legacy "Speakr" / #2563eb. Greetings now
use user.name with fallback to user.username. Subjects:
"Vérifiez votre courriel — DictIA" + "Réinitialiser votre mot de passe — DictIA".
SMTP_FROM_NAME defaults to DictIA. Footer points to info@dictia.ca with the
Loi 25 tagline.
Refonte 4 auth templates IN PLACE pour étendre marketing/base.html : check_email,
forgot_password, reset_password, verify_success. Tokens DictIA (brand-navy,
brand-bg, grad-bg, shadow-cta), French copy, WCAG patterns (label for,
focus-visible:outline-2, role=alert, aria-required, text-brand-navy/70 minimum,
NBSP français pour Loi 25 / 24 heures / 1 heure / 8 caractères).
Translate inline French flash messages in src/api/auth.py for /verify-email,
/resend-verification, /forgot-password, /reset-password. Anti-enumeration fix:
forgot_password no longer flashes the cooldown remaining (would leak account
existence) — silently skips resend, generic flash unchanged. Cooldown logic
in src/services/email.py UNCHANGED (60s — verified by test).
config/env.email.example: defaults to Resend SMTP at the top + adds Resend
to the provider examples list (preserves Gmail/SendGrid/Mailgun/SES/M365).
Tests: tests/test_email_service_dictia.py — 12 tests covering DictIA branding,
French copy, display-name fallback, anti-enumeration parity (forgot_password
returns identical message for known/unknown emails), 60s cooldown, SMTP-not-
configured returns False (no exception), check_email.html extends marketing/base
(no var(--text-primary) leaks). Includes Windows manual driver
(_run_email_service_dictia_windows.py) since pytest cannot collect on Windows
native (fcntl POSIX-only).
NO new dependency added (no resend SDK — SMTP via existing _send_email).
NO new route added or removed.
NO src/auth_extended/ created.
NO change to itsdangerous-based token logic.
templates/auth/**/*.html already in tailwind.config.js content array (B-2.2).
Verified locally on Windows manual driver: 12/12 PASS B-2.3, 9/9 PASS regression
on B-2.2 signup, 9/9 PASS regression on B-2.1 ConsentLog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C-1: Add templates/register.html (and templates/auth/**) to tailwind.config.js
content array so utility classes used by the signup template don't get purged
on next build. Rebuilt static/css/marketing.css; verified text-brand-navy/90
and min-h-[calc(100vh-62px)] are now compiled.
I-1: Replace flash() calls for missing required consents with WTForms
field-level errors (form.consent_cgu.errors.append / form.consent_confidentialite
.errors.append). Errors render inline next to each consent checkbox via
{% if form.consent_cgu.errors %}<p role="alert">…</p>{% endif %}. Prevents
session-backed flash messages from leaking across unrelated navigations.
I-2: Wrap user creation + flush in IntegrityError retry loop (max 5 attempts);
import IntegrityError from sqlalchemy.exc. Absorbs the inherent race between
_generate_unique_username's lookup and the subsequent flush under concurrent
signups. Added docstring note to _generate_unique_username explaining the
wrapper.
I-3: Move db.create_all() inside the try/finally in
test_signup_route_csrf_enforced so WTF_CSRF_ENABLED is restored even if
table creation fails.
I-4: Pin test_signup_rejects_duplicate_email assertion to status_code == 200
(WTForms validate_email raises ValidationError → form fails validation →
fall-through to default 200 render_template).
I-5: Add id="password-help" to the password help paragraph and
aria-describedby="password-help" to the password input so screen readers
announce the password requirements when the field is focused.
I-6: Bump flash banner text colors from -700/-800 to -900 variants
(text-amber-900, text-blue-900, text-red-900, text-green-900) for safer
WCAG 2.2 AA contrast against the -50 backgrounds. Same bump applied to the
new consent and password inline error renders.
- Loi 25 article number badges (Art. 3.3, 3.5, 14): change from
`grad-bg text-white text-xs font-black` to `bg-brand-navy text-white
text-xs font-black`. White-on-grad-bg failed AA on the cyan/green
portion of the gradient (~2:1 contrast); solid navy gives ~16:1.
- Update routes.py module docstring to past tense (A-2.8b is now done).
- Add regression assertion ensuring badges use solid navy, not grad-bg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract 3 pricing tiers to templates/marketing/_partials/_pricing_tiers.html
Single source of truth — landing.html and tarifs.html now {% include %} it.
Prevents price drift (LPC art. 219 risk).
- Sync bento card #2 description across landing + fonctionnalites
(was diverged: 'embeddings' vs 'embeddings vocaux'). Add maintenance
reminder comments in both files.
- Fix OQLF NBSP on '~2 semaines' matrix cells in /tarifs deep-dive table.
- Fix mixed UTF-8/entity 'québécois' -> 'québécois' in tech
specs (consistent with rest of file).
- Calibrate H2 size on /tarifs FAQ to match landing (clamp 2.75rem cap).
- Repair 2 pre-existing test bugs from earlier A-2.x commits:
* 'violent la Loi 25' -> accept both NBSP and plain forms (commit 7c6c6fd
added the NBSP after the test was written)
* 'résilie' -> 'résilie' (Jinja outputs raw UTF-8, not entities)
- Update src/marketing/routes.py module docstring to reflect 2/4 done.
- Drop role="region" from FAQ panels (had no accessible name — axe-core
violation; disclosure pattern with button + aria-controls + aria-expanded
is sufficient per WAI-APG accordion guidance)
- Add focus-visible:outline-2 outline-brand-b1 outline-offset-2 to FAQ
buttons (WCAG 2.2 AA 2.4.7 Focus Not Obscured + 2.4.11 Focus Appearance —
Safari default focus indicator is unreliable)
- Sweep pre-existing text-white/50 on Hero social proof microcopy → /70
(branch-wide WCAG floor; recurring landmine flagged at A-2.7a review)
- Strengthen test_faq_jsonld_schema_present to json.loads() the extracted
block and validate the FAQPage schema shape (regression guard for future
content edits with unescaped backslashes/quotes)
- Reword comparatif row 1 to make Teams ✗ for non-Loi-25-compliant US transfer
(was ⚠ — too soft; territoriality is binary)
- Reword row 2 to positive: 'Souveraineté hors Cloud Act US' (was inverted —
✓ used for bad outcomes, breaking visual scan convention)
- Reword row 4 criterion to match deliverable: 'Diarisation jusqu'à 8 locuteurs'
(was '8+', mismatched DictIA's '✓ Jusqu'à 8' cell)
- Reword row 5 to 'Coût mensuel par utilisateur' (was 'utilisateur/mois' —
awkward French + missing NBSP per OQLF)
- Hedge SOC 2 Type II claim about OVH Beauharnois — third-party certification
scope-dependent; reword to 'conformité documentée selon les services
(ISO 27001, SOC 2 selon le périmètre)' to avoid LPC art. 219 risk
- Replace 🇨🇦 regional indicator pair with 🍁 maple leaf — renders reliably
on Windows + Firefox Linux without color-emoji fonts
- Update existing test for renamed criteria keywords
- Add regression test for ✓-means-good convention + SOC 2 hedge guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ROI payback now returns raw months; template branches to 'moins d'un mois'
for sub-month paybacks and 'Payable dès la première année' when savings≤0
(was rounding up to 'Payback : 1 mois' for ~95% of slider combos)
- Cap sliders: users 1..25 (was 50), hours 0.5..4 (was 8) to keep displayed
savings in a defensible band (~8.8 M$/yr max instead of 35 M$)
- pricing_card href uses cta_url.rstrip('/') to avoid double-slash if caller
passes a trailing slash (preempts A-2.8 / B-2.7 regression)
- aria-live polite + aria-atomic on the savings paragraph so screen readers
announce slider updates
- Cleaner JS module pattern: single window.roiCalculator = function() {...}
- Tests updated for payback ternary; new tests for slider caps, aria-live,
and double-slash guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Pipe macro title/description through | safe to render NBSP/& correctly
(autoescape was producing literal '95 %+' and 'Q&R' text on screen)
- Replace dynamic col-span-{{ span }} with static lookup table so Tailwind
scanner generates the utilities for A-2.7+ reuse
- Replace inline border style with border-white/[0.045] utility (codebase consistency)
- Add explicit Q&R assertion + autoescape regression guard test