Compare commits

75 Commits

Author SHA1 Message Date
Allison
224e51cc81 feat(marketing): refonte narrative 'Comment ça marche' — processus 4 étapes step-by-step (capture → IA → distribution → gouvernance)
Au lieu d'un cycle générique 1→12, l'animation suit maintenant un FLOW PROCESSUS
canonique qui raconte l'histoire produit du début à la fin :

  ÉTAPE 1 · CAPTURE          → Recording (7) + Transcription (1)
  ÉTAPE 2 · TRANSFORMATION IA → Diarisation (2) + Langues (3) + Résumé (9) + Chat IA (0)
  ÉTAPE 3 · DISTRIBUTION     → Exports (4) + Partage (6) + Users (5) + Intégrations (10)
  ÉTAPE 4 · GOUVERNANCE      → Recherche IA (8) + Audit (11) + Conformité (12)

PROCESS_ORDER = [7, 1, 2, 3, 9, 0, 4, 6, 5, 10, 8, 11, 12] — 13 sub-modes,
mode 0 IA inclus dans le cycle (au lieu d'être skippé), 1100ms × 13 ≈ 14.3s
tour complet.

Composants ajoutés / refondus :
- PROCESS BREADCRUMB visible au-dessus du phone (4 pilules · flèches · past/active/future)
- PROGRESS BAR sous breadcrumb (gradient brand-b1 → b2 → b3, role=progressbar)
- BOTTOM TAB BAR : 4 boutons étapes (au lieu de 6 features individuelles)
- RIGHT PANEL : 4 mini-sections par étape (12 boutons regroupés selon flow)
- FEATURE INFO CARD : préfixe 'Étape X/4 · TITRE' pour contexte processus
- CONNECTING LINE : flèche directionnelle + dot animé qui se déplace selon processIdx
- Mobile pills : suivent PROCESS_ORDER (13 sub-modes au lieu de 1-12)

Alpine refactor :
- selectedFeature devient un getter computed sur PROCESS_ORDER[processIdx]
- nouveau STEPS array avec subModes mapping + activeStep / progressPercent getters
- goToStep(id) helper jump au 1er sub-mode d'une étape
- handleManualSelect(i) résout l'index dans PROCESS_ORDER

Préservé : palette brand stricte (b1/b2/b3/navy), V3 radii, Inter+JetBrains Mono,
phone shell statique, 13 templates de modes, IA Mistral premium card, eyebrow
'COMMENT ÇA MARCHE' brand-navy, WCAG (aria-current, aria-label, role=navigation,
role=progressbar, role=tablist), prefers-reduced-motion guards.

Tests : 9/9 fonctionnalites passent (test how_it_works_reactor étendu de +37 assertions
narratives : PROCESS_ORDER, STEPS, processIdx, activeStep, goToStep, breadcrumb,
progressbar, step-bottom, step-section, contexte processus dans card).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:31:30 -04:00
Allison
a14bcb9a1a fix(marketing): restaurer visibilité 12 fonctions dans 'Comment ça marche'
L'abstraction en 4 catégories (commit 8a7650f) cachait les 13 modes
derrière des regroupements ; l'utilisateur ne VOYAIT plus les 6 boutons
features originaux et percevait que des fonctions avaient disparu.

Refonte v2 :
- Bottom tab bar : 6 boutons FEATURES originaux 1-6 (Trans/Diari/Lang/Exp/Users/Part) — visibles, cliquables
- Right panel : grid 3×4 = 12 boutons FEATURES 1-12 (IA mode 0 reste accessible via Brain card)
- Mobile pills : 12 features scrollables horizontalement
- Auto-cycle : 1→12 skip 0, 1100ms each (~13s cycle complet)
- Manual click : isManual 4500ms reset puis reprend auto
- SUPPRESSION : CATEGORIES array + activeCategory state + handleCategorySelect + sub-mode dots indicator
- AJOUT : featureGridLabel() pour labels compacts (Recording/Recherche IA/Résumés/Intégrations/Audit trail/Conformité)

Préservé : phone shell statique, palette brand stricte b1/b2/b3, IA Mistral
card inchangée, 13 templates de modes (0-12) intacts, eyebrow brand-navy noir,
WCAG aria-labels + aria-pressed, prefers-reduced-motion guard.

Test adapté : assert featureGridLabel + 12 fonctions + labels grid.
2026-04-29 13:17:09 -04:00
Allison
8a7650f9fa feat(marketing): refonte stratégique 'Comment ça marche' — 4 catégories × 12 sous-modes
Audit complet des fonctionnalités réelles DictIA (manuel utilisateur + composants
production) puis restructuration de l'animation phone container en 4 catégories
logiques regroupant 12 sous-modes (6 historiques + 6 nouveaux).

CATÉGORIES (bottom tab bar 4 boutons + sub-mode dots indicator) :
- Capture (b2 cyan)         : Transcription, Recording live, Recherche IA
- Transformation IA (b1)    : Diarisation, 99+ langues, Résumé+actions, Chat IA
- Distribution (b3 fuchsia) : Exports, Intégrations Hub, Partage, Users
- Gouvernance (b1 blue)     : Audit trail, Conformité Loi 25 + 9 ordres pros

NOUVEAUX MODES IMPLÉMENTÉS :
- Mode 7 Recording live : minuteur 99s + waveform 24 bars random + 3 boutons
  sources (mic/système/combiné) selon manuel utilisation v1.0
- Mode 8 Recherche sémantique : query typed + 3 résultats highlight RAG
- Mode 9 Résumé + actions : décisions/actions stagger + extraction ICS
- Mode 10 Intégrations : hub central DictIA + 8 logos en orbite (Word, Outlook,
  Teams, Notion, Obsidian, Zapier, Make, n8n) + lignes connexion SVG
- Mode 11 Audit trail : 6 events horodatés (INFO/AUTH/PROC/READ/EXP/SHARE) +
  badge consentement tracé immutable (Loi 25 art. 8)
- Mode 12 Conformité Loi 25 : 6 badges (Loi 25/96/EFVP CAI/MCN/AGPL/0 Cloud
  Act US) + 9 ordres pros (Barreau, CNQ, CPA, ChAD, OACIQ, CMQ, OIIQ, OPQ, OEQ)

REFACTOR Alpine dictiaDashboard() :
- FEATURES étendu de 7 à 13 entrées (idx 0-12)
- CATEGORIES array avec submodes[], iconPath, color, subtitle
- activeCategory + handleCategorySelect(ci) en plus de handleManualSelect(i)
- Auto-cycle 1100ms entre sous-modes ; switch catégorie quand fin atteinte
- Right grid 3×6 → 2×2 categories cards (preview sub-modes dots)
- Bottom tab bar 6 modes → 4 catégories (icons larger 18px) + sub-mode dots
- Mobile pills par catégorie (au lieu de par mode)

Préservé : palette brand-b1/b2/b3 stricte, phone shell statique 280×580,
WCAG AA, prefers-reduced-motion, eyebrow text-brand-navy, IA Mistral card,
section integrations, architecture, conformité-resume.

Tests : 9/9 passent dans test_fonctionnalites_*. Assertions ajoutées pour les
4 catégories + 6 nouveaux modes + handleCategorySelect + 1100ms cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:57:44 -04:00
Allison
323f0c81c4 fix(marketing): contraste/lisibilité 'Comment ça marche' — feature card + grid buttons WCAG AA
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>
2026-04-29 11:03:37 -04:00
Allison
7d3348c3fd polish(marketing): refonte HYPER PRO 'Comment ça marche' — purge non-brand hex + uniformization typo/spacing
Refactor mécanique strict :
- Purge 100% des hex non-brand (#0891b2, #a21caf, #e879f9, #1d4ed8, #9333ea, #f5d0fe, #67e8f9, #1e40af, #93c5fd, #9CA3AF, #0e7490, #EF4444 capital) → mapping vers brand-b1/b2/b3
- Standardisation tailles : 0 inline font-size, text-[9px/10px/11px] uniquement (purge 5.5/6/6.5/7/7.5/8/8.5 arbitraires)
- 0 font-family inline (utilise font-sans/font-mono Tailwind)

Polish device :
- Inner screen seam (effet "écran encastré dans bezel")
- Notch : intègre speaker grille 3 dots + camera dot dans la dynamic island
- Status bar : vraie batterie 80% fill, vrai WiFi 3 arcs concentriques + dot, signal 4 bars croissantes
- Logo DictIA 92×28 plus grand (opacité 85%)

Polish modes :
- Mode 1 : header compact mic+filename+REC, waveform 16 bars symétriques, file card MP3 redesign avec corner fold
- Mode 2 : avatars empilés 18×18 avec bordure white/15, bubbles max-width 80%, timestamps text-[9px]
- Mode 3 : grille langues text-[10px] line-height 18px, padding 8px, palette stricte b1/b2/b3
- Mode 4 : grid 4×2 cards 42×50, drop staggered 90ms, palette stricte b1/b2/b3 + dc2626 PDF + 374151 TXT
- Mode 5 : header counter Inter font-black text-base, connecting lines opacité 0.18
- Mode 6 : breadcrumb compact, toolbar 4 icons, hover row highlight, palette b1/b2/b3
- Mode 0 : chat bubbles uniformisés text-[10px], footer shield emerald (sécurité)

Polish right panel IA :
- Brain 40×40 cercle gradient brand-b3 (déjà OK)
- Badges Mistral 7B (b3 bg) + LOCAL (emerald bg)
- 3 metrics : 0ms grad-text · 100% emerald · 24/7 grad-text font-black text-lg
- Sovereignty bullets : icon dans cercle 20×20 rounded-full bg-brand-b3/[0.15]
- Padding p-5 généreux

Polish feature info card sous phone :
- Background uniforme bg-white/[0.06] + border-white/[0.10]
- Border-left 3px accent activeColor (style tab indicator)
- Icon container 32×32 rounded-md
- Badge top-right text-[10px] tracking-wider

Polish bottom tab bar :
- Buttons 34×42, gap-1 serré
- Active : bottom border 2px + scale icon 1.15 + drop-shadow color
- Labels text-[9px] uppercase tracking-wider
- AUTO pill : px-2.5 py-0.5 rounded-full bg-emerald/12

Tests :
- +6 assertions polish (forbidden hex purge, screen seam, white/0.06, brand-b3/[0.15], grad-text)
- 9/9 fonctionnalites tests pass
- 29/29 marketing tests pass (2 conformite failures pré-existantes baseline)

Build : npm run build:css → static/css/marketing.css régénéré pour les nouvelles classes arbitraires

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:49:47 -04:00
Allison
d6ff71640a fix(marketing): phone shell border/shadow/glow STATIC (ne morphe plus entre modes)
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>
2026-04-29 10:32:25 -04:00
Allison
199b315bc2 fix(marketing): phone container width stable + palette brand canonique (b1/b2/b3) sur Comment ça marche
- Phone shell hauteur/largeur FIXES (280×580px) — bezel ne reflow plus
  selon le mode actif. Zone TOP 96px / MIDDLE 374px overflow:hidden /
  BOTTOM 90px. Chaque mode a maintenant w-full h-full overflow-hidden.
- Modes 1/2/3/6/0 : scroll interne invisible (.dictia-mode-scroll +
  fade gradient bottom .dictia-fade-bottom) pour contenu long.
- Suppression mécanique des hex non-brand : #A78BFA, #22D3EE, #6B9FFF,
  #34D399, #F59E0B, #1E6FD9, #7C3AED, #5B21B6, #065F46, #1C3A5E,
  #D93E1E, #C4B5FD, #DDD6FE, etc. — remplacés par brand-b1 (#2563eb),
  brand-b2 (#06b6d4), brand-b3 (#c026d3) et leurs déclinaisons
  (#0891b2, #1d4ed8, #a21caf, #1e40af, #0e7490, #9333ea, #e879f9).
- FEATURES / CONVO / LANG_COLORS / USER_COLORS / FILE_TYPES réalignés
  sur palette officielle. Sophie=b2, Marc=b1, Julie=b3.
- Status sémantiques conservés : #EF4444 (REC dot), #10b981 (online
  status / 7 FORMATS PRÊTS / LOCAL badge), #dc2626 (PDF file icon).
- font-family:monospace inline → font-mono Tailwind (JetBrains Mono).
- Mobile : phone reste à 280px fixe, scale 0.92 en dessous de 320px.
- Eyebrow + connecting line gradient SVG en couleurs brand.

Tests : 9/9 fonctionnalites passent. 2 échecs préexistants sur
/conformite (SOC 2 hedge / AGPL section) sans rapport avec ce fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:22:57 -04:00
Allison
5edaddd788 polish(marketing): refonte HYPER PRO 'Comment ça marche' — bezel iPhone, status bar, sound waves, modes enrichis (REC, waveform, typing, ripple, breadcrumb), IA card 40px brain + metrics, connecting line
10 améliorations cinématiques sur la section interactive :

PHONE FRAME RÉALISTE
- Bezel double border + glow ring externe pulsant
- Notch (Dynamic Island stylisé) + speaker grille + camera dot
- Status bar mobile (9:41, signal/wifi/batterie SVG)
- Drop shadow dramatique + inner shadow encastrée

MIC + SOUND WAVES
- 3 ripples concentriques émanant du mic (sound-ring keyframes)

MODE 1 TRANSCRIPTION
- Header REC indicator pulsant (rouge) + nom fichier
- Waveform animée (12 bars hauteurs randomisées)
- Barre progress double avec gradient + glow

MODE 2 DIARISATION
- Header conversation : 3 avatars empilés overlapping
- Bubbles avec timestamps (09:0X) + box-shadow
- Typing indicator (3 dots) entre messages

MODE 3 LANGUES
- Header DÉTECTION AUTOMATIQUE avec dot pulsant
- Ripple wave depuis le centre (rippleDelay calculé par distance)
- 5 langues highlight aléatoire glow brièvement
- Counter live FR · EN · ES · ... + 99+ détectées

MODE 4 EXPORTS
- Grid 4×2 organisé (au lieu de wrap aléatoire)
- Files détaillés avec mini-pages (3 lignes texte)
- Subtitle '7 FORMATS PRÊTS' + checkmark vert

MODE 5 USERS
- Counter centré 01 → 20 (font-mono black)
- 5 USER_COLORS variations (purple/cyan/green/blue/amber)
- Connecting lines SVG pointillés vers centre

MODE 6 SHARE
- Breadcrumb 'Mes dossiers › Réunions'
- Toolbar mini (search, filter, sort)
- Rows structurées avec 3-dots action

BOTTOM ZONE
- 6 boutons agrandis (30+px) avec labels + tab indicator border 2px
- AUTO pill (badge dot pulse) / Manuel countdown bar 4.5s

CARD INFO
- Icon container 32×32 + badge top-right + hover lift

RIGHT PANEL IA
- Brain dans cercle 40×40 gradient + glow
- Metrics row : 0ms latence · 100% privé · 24/7
- Sovereignty bullets : icon dans rounded box

GLOBAL
- Section background : grid pattern + 2 floating orbs blur
- Connecting line SVG cyan→violet phone↔IA panel
- Header section : eyebrow gradient pill + stats row (6/99+/0) + CTA démo
- Crossfade transitions modes (scale 0.96 → 1)

ACCESSIBILITÉ
- prefers-reduced-motion désactive toutes animations
- @media (max-width: 767px) désactive éléments décoratifs CPU-intensive
- aria-pressed, aria-live polite, focus-visible préservés
- 30 nouveaux keyframes CSS scopés

Tests : 1 existant + 1 enrichi (53 lignes, 22 nouvelles assertions)
Tests pass : 30/30 sur how-it-works (les 2 fails conformite sont pré-existants).
2026-04-29 10:04:04 -04:00
Allison
7aaedf2cdf refactor(marketing): reproduit fidèlement DashboardHolographique de dictai-narrative.tsx (6 modes uniques + auto-cycle 900ms)
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).
2026-04-29 09:37:09 -04:00
Allison
03f6e56f04 feat(marketing): section interactive 'Comment ça marche' (réacteur DictIA cyclant 6 features)
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>
2026-04-29 09:09:40 -04:00
Allison
e06cba2123 refactor(pricing): 3 Cloud en rangée + DictIA LOCAL bloc dédié 'Vous en êtes propriétaire'
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>
2026-04-28 21:28:46 -04:00
Allison
1c4cafaf69 refactor(pricing): refonte v7.0 — 3 Cloud (Basic 189$/Essentiel 349$/Pro 549$) + DictIA Local (5998$ An1) + Pro+ soumission
Remplace l'ancien pricing (DictIA 8 / 16 / Cloud) par la nouvelle structure
canonique v7.0 : 4 forfaits + 1 sentinel quote-only.

Changements clés :
- pricing_card.html : signature étendue (badge, recommended, capacity_audio,
  capacity_storage, gpu, yearly_renewal, cta_label) + format prix server-side
  avec NBSP OQLF (5998 -> 5&nbsp;998&nbsp;$)
- _pricing_tiers.html : 4 cards (Cloud Basic 189$, Cloud Essentiel 349$,
  Cloud Pro 549$+485$ RECOMMANDÉ, DictIA Local 5998$ An1) + chip Pro+
  soumission -> /contact?pro-plus=1
- plans.py : refonte complète avec yearly_renewal_env (DictIA Local An 2+ =
  500$/an) + is_quote_only sentinel (Pro+ -> redirect /contact, jamais Stripe)
- routes.py : Pro+ intercepté avant le flow Stripe Checkout
- env.stripe.example : nouveau naming STRIPE_CLOUD_BASIC|ESSENTIEL|PRO_*
  + STRIPE_DICTIA_LOCAL_SETUP/RENEWAL_YEARLY
- tarifs.html : header "Quatre forfaits", matrice comparative 4 colonnes,
  FAQ enrichie (7 questions incluant DictIA Local + onboarding Pro + Pro+)
- fonctionnalites.html : section Architecture refondue (4 cards v7.0)
- landing.html : ROI footnote + cycle "189$" + wave "189$/mois" actualisés
- roi_calculator.js : recalibrage sur Cloud ESSENTIEL 349$ × 12 = 4188$/an
- routes.py marketing : FAQ "DictIA 8 et 16" -> "DictIA LOCAL"
- contact.html : "déploiements DictIA 16" -> "Cloud PRO" + "DictIA LOCAL"

Tests :
- test_marketing_landing_template.py : assertions prix v7.0 (189/349/549/5998),
  4 slugs (cloud-basic, cloud-essentiel, cloud-pro, dictia-local), Pro+ chip,
  capacity chips, RECOMMANDÉ sur Cloud PRO
- test_marketing_secondary_pages.py : 4 cards + Pro+ chip + matrice 4 col +
  FAQ 7 questions
- test_stripe_checkout.py : env vars v7.0, slugs cloud-basic/cloud-pro/
  dictia-local + nouveau test pro-plus -> /contact + tests setup pour Cloud PRO
  et DictIA Local
- test_stripe_webhook.py : plan_slug metadata cloud-basic

Status : 28/28 Stripe checkout + 17/17 webhook + 93/98 marketing pass
(les 5 marketing failures sont pré-existantes, non liées au pricing :
test_landing_has_main_nav et test_footer_links_complete = /blog manquant ;
test_trust_bar_has_eyebrow_factual_phrasing + 2 tests conformite =
casing eyebrow + entité é — vérifié par git stash baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:06:12 -04:00
Allison
e8c7e5cd43 refactor(marketing): Cycle cinematic PRO — palette brand uniformisée + USA map + Quebec outline + halo brand
Round 5 cinématique de la section "Trois options. Une seule est conforme." :
- Palette brand uniformisée (blue/cyan/fuchsia) — col 3 passe de emerald/green à brand-b1+brand-b2 + accents fuchsia
- Connecting horizontal beam progressive 0→33→66→100% entre les 3 colonnes (gris→rouge→cyan)
- Spotlight active column (opacity 0.65 inactif, 1 actif + scale 1.02)
- Col 1 : stack papiers stagger reveal 180ms + horloge 2.5s + counter \$315
- Col 2 : USA map silhouette subtle bg + server rack 3-leds + 12 paquets data .wav/.aac flying vers top-right + screen shake + flash red + chevrons + sound icon pulse
- Col 3 : Quebec province outline subtle + mini logo DictIA + halo multi-couches blue+cyan+fuchsia + drawn ring SVG (fuchsia accent) + shield-with-microphone + checkmark cyan + badge Loi 25 conforme gradient brand + big 173 \$ en grad-text
- Section Économies : counters en grad-text + save chips "+économies" + chips contextuels + icônes distinctives (loutre/Teams T/sténographe humain)
- Phase delays cinématiques 400/1100/2000/3100ms + cubic-bezier overshoot
- Vignette ambiante + grid pattern 40×40 + orbes décoratifs blur

WCAG : aria-labels préservés + prefers-reduced-motion désactive radar/particles/screen-shake/halo/beam.
Mobile : leak particles + orbes + halo heavy + screen shake désactivés via @media (max-width: 768px).
Performance : will-change GPU hints sur stamp/halo/leak.

Tests cycle round 4 (3) toujours passants. 65/68 tests pass (3 failures pré-existantes : /blog nav).
HTTP 200 vérifié sur http://127.0.0.1:8899/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:07:59 -04:00
Allison
575db5e342 feat(brand): logo officiel DictIA + palette blue/cyan/fuchsia (matche le logo)
Logos officiels installés :
- static/images/dictia-logo.png (28 KB optimisé 256×256)
- static/images/dictia-logo-128.png (10 KB retina)
- static/images/dictia-logo-fullres.png (originaux conservés OG/social)
- static/images/dictia-logo.svg + dictia-logo-nom.svg (cleaned C2PA metadata)
- Header marketing/base.html : <img> 40×40 + wordmark "DictIA" + tagline "Transcription"
- Footer marketing/_footer.html : <img> 36×36 + wordmark
- Favicon mis à jour vers logo PNG

Note : SVG sources sont des PNG base64 wrappés (pas de vrais paths) — PNG utilisé
en production (8× plus léger), SVG conservé pour fallback.

Palette canonique alignée sur le logo :
- brand-b1 : #7c3aed (mauve) → #2563eb (blue-600 vibrant — primary)
- brand-b2 : #a855f7 (mauve clair) → #06b6d4 (cyan-500 — aqua mid)
- brand-b3 : #06b6d4 (aqua) → #c026d3 (fuchsia-600 — magenta accent)
- Gradient signature : linear-gradient(118deg, #2563eb, #06b6d4 52%, #c026d3)
- Box shadow CTA : rgba(37,99,235,0.28/0.42)
- 72 remplacements hex/rgba dans 5 templates marketing/legal + email service

Tests : 81 passed / 3 failed (3 échecs pré-existants /blog + trust-bar phrasing,
non liés à ce changement). 0 régression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:17 -04:00
Allison
34d40162b3 refactor(brand): décale palette bleu→mauve dégradé (b1=#7c3aed, b2=#a855f7, b3=#06b6d4 aqua) pour différencier DictIA
Décale la palette canonique DictIA du bleu/cyan/vert vers mauve/violet/aqua
afin de distinguer visuellement le produit DictIA des autres outils InnovA AI
(qui restent sur palette bleue) tout en gardant la même structure de gradient,
mêmes tokens Tailwind, et même intensité visuelle.

Mapping appliqué:
- Hex semantic: #0062ff → #7c3aed (mauve), #00bdd8 → #a855f7 (lighter mauve)
- Hex aqua décoratif: #00c896 → #06b6d4 (cyan-500, aqua préservé)
- Hex secondaire: #6B9FFF / rgba(107,159,255,*) → #a78bfa (violet-400)
- Hex blue-700 #1d4ed8 (cadre reg label) → #7c3aed (mauve)
- Box shadows / rgba opacités: rgba(0,98,255,*) → rgba(124,58,237,*)
- Décoratif (orbes cosmiques, glows): mauve+aqua mix pour préserver l'effet
  "cosmic dégradé" — orbe primaire en mauve, orbes secondaires en aqua
- Hub network DictIA: Documents=mauve, Communication=aqua, Automatisation
  reste #8b5cf6 (déjà violet, marqueur visuel distinct via positionnement)
- Couleurs sémantiques fonctionnelles (red/green pour erreurs/succès, amber
  pour alertes) inchangées

Fichiers modifiés:
- static/css/tailwind.config.js (brand.b1/b2/b3 + brand-grad + boxShadow.cta
  + boxShadow.cta-hover + keyframes.tc-pulse-glow)
- static/css/marketing.css (rebuild Tailwind: 169356 → 163036 bytes)
- templates/legal/{_layout,index}.html
- templates/marketing/{landing,fonctionnalites,conformite,tarifs}.html
- tests/test_marketing_landing_template.py (test_hero_has_cosmic_orbs_background
  mis à jour avec assertions mauve/aqua au lieu de blue/cyan/green)

Hors scope (non touchés):
- Couleurs Tailwind utility (red/green/amber/emerald) sémantiques
- --brand-navy* (backgrounds dark restent neutres)
- Templates legacy (account.html, admin.html, components/, modals/)
- #8b5cf6 (Automatisation hub), #f59e0b (alertes), #ef4444 (erreurs)

Tests: 111 passed, 5 failed (toutes 5 pré-existantes, non liées aux couleurs:
/blog link manquant, MAPP eyebrow, SOC 2 phrasing, Gitea URL).
HTTP 200 vérifié sur /, /fonctionnalites, /tarifs, /conformite, /legal/,
/legal/conditions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:39:09 -04:00
Allison
680df39089 feat(marketing): round 4 — Cadre + Cycle cinématiques (radar, data packet flight, stamp impact, savings counter)
Round 4 transforme les 2 sections "Cadre réglementaire" en expériences cinématiques :

CADRE (Moniteur d'Interception)
- Radar sweep circulaire vert continu en background HUD (4s loop, SVG + @keyframes)
- 6 paquets data "voice.wav" en flight QC→US via offset-path bezier (stagger 420ms, glow rouge)
- Console typewriter char-by-char 3 lignes (28ms/char + caret blink, 3e ligne rouge glow)
- 6 REGS reveal cascadé via revealRegsCascade (stagger 120ms) + hover red glow + border-left
- Verdict NON CONFORME : pulse glow rouge + scan-line traversante 3s
- Decorative grid 40×40 console-style + grid existant 20×20
- Eyebrow ⚠ remplacé par SVG warning-triangle inline

CYCLE (Trois options)
- Phase reveal 1→4 séquentiel (déjà existant) avec animations renforcées
- Col 1 horloge accélérée 1 tour/3s (au lieu de 8s)
- Col 1 prix counter Alpine 0→315 (easeOutCubic 1.4s) via priceHumain + countTo
- Col 2 stamp NON CONFORME impact (rotate -22→-3deg + scale 2.4→1, cubic-bezier 1.6 ease)
- Col 2 flash rouge background à l'impact (cycle-col-flash) + 10 particules de fuite (au lieu de 6)
- Col 3 checkmark draw via stroke-dashoffset 24→0
- Col 3 glow border vert pulsant (cycle-conforme-glow, double-couche emerald + cyan)
- Col 3 badge "Loi 25 conforme" top-right avec pulse subtil (cycle-conforme-badge)
- Connecting lines avec dash flow continu (cycle-line-flow @keyframes)
- Live red dot "Réunion en cours" avec pulse box-shadow
- Section "Économies annuelles · 25 utilisateurs" : 3 cards avec counter Alpine
  (sav1=3924, sav2=6924, sav3=2004) + hover lift + emerald shadow
- Eyebrow ⚠ remplacé par SVG warning-triangle

Accessibilité & performance
- prefers-reduced-motion désactive TOUT (radar, packets, typewriter, stamp, glow, counter)
- Mobile (<768px) cache radar + packets + leak particles (CPU-intensive)
- Counter helper countTo respecte reduced-motion via matchMedia
- Tous les SVG ont aria-hidden, scènes ont role=img/listitem appropriés
- HUD console role=log + aria-live=polite
- OQLF NBSP préservé (315 $/réunion, Loi 25, 100 % Québec, 25 utilisateurs, 3 924 $)

Tests : 4 tests round 4 ajoutés (cadre cinematic, cycle cinematic, no-emoji warning,
reduced-motion guards). 65/68 landing tests passent (3 failures pré-existantes
unrelated : nav /blog, footer /blog, trust-bar phrasing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:11:02 -04:00
Allison
529bd2263b feat(marketing): round 3 — hero remplacé par 3-step flow canonique + 99+ langues + Cégeps spotlight + CyberPerformance + FAQ enrichie
Hero (templates/marketing/landing.html) :
- Reproduction fidèle de dictia.ca/solutions/dictai (source : Website-Sanity/components/sections/dictai-page-content.tsx lignes 260-518)
- REMPLACE le mockup app DictIA par le 3-step flow inline canonique (Importez → Texte 2 min → Résumé + actions)
- Wordmark large « DictIA » (style production) + H2 cyan « Transcription IA locale en 2 minutes — conforme Barreau, CPA Québec et ChAD »
- Sub canonique référençant OVH Beauharnois, Cadre IA MCN, 5 ordres à directives IA formelles
- Stats grid (4 col) : ~2 min · 5 ordres · 95 %+ · 0 $ (NBSP OQLF)
- Eyebrow back-link « Toutes les solutions »
- 5 animations Framer Motion → CSS pure + Alpine.js :
  1. 3-step flow auto-cycle 1→2→3 (setInterval 1.8 s, désactivé reduced-motion)
  2. Magnetic CTA primary (mousemove → translate max 8 px)
  3. Mouse parallax orb 3D (mousemove window → CSS transition)
  4. Shockwave on click (CSS pseudo-element scale 0→4 + opacity)
  5. Word-staggered title reveal (Dict + IA via animation-delay)

Sections enrichies / ajoutées :
- Pipeline : sous-titre « Du fichier au résumé — en temps réel » + hint canonique
- NEW « 99+ langues détectées » + carte « IA Mistral 7B (LOCAL) » 4 bullets
- Pricing : sous-titres canoniques par forfait + note « Tous les prix en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %) »
- Conformité : 3 chips claims (~192 000 pros · 5 ordres · 0 donnée hors-Québec) + phrase secteurs réglementés
- NEW Cégeps spotlight « Conformité au 19 juin 2026 » avec Cadre IA MCN détaillé (7 bullets, 9 chips organismes, badge pulse glow)
- NEW Partenaire CyberPerformance (card horizontale + lien externe)
- FAQ : enrichie de 7 → 10 questions canoniques sourcées de dictai-page-content.tsx (Teams Copilot, Otter.ai, Barreau, Clio Manage, etc.)
- CTA final : « Prêt à protéger vos données ? » + bouton « Réserver ma démo gratuite » (préserve mailto pré-inscription)

Tests :
- Ajout tests/conftest.py (stub fcntl POSIX + env vars test) pour permettre exécution sur Windows
- Mise à jour 8 assertions liées au nouveau hero, FAQ 10 Q, CTA renforcé, NBSP OQLF dans eyebrow
- 61 passed / 3 pré-existant échecs baseline (/blog dans nav + footer + trust-bar phrasing)

Contraintes respectées : zéro JS externe, aucun emoji (SVG inline aria-hidden), V3 radii (rounded/rounded-full), brand tokens, OQLF NBSP partout, WCAG (aria-labels, focus-visible, prefers-reduced-motion désactive toutes les animations hero).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:43:57 -04:00
Allison
69baa1be2f feat(marketing): round 2 — intègre 3 sections de dictia.ca/solutions/dictai (cycle/wave/cadre)
- Cycle "Trois options. Une seule est conforme." (entre PAS Problème & Solution)
  3 colonnes comparatives (humaine 315$/h / cloud US illégal / DictIA 173$/mo)
  Phases reveal 1→4 via IntersectionObserver + setTimeout chain
  Anneaux pulsants source node + horloge rotation + particules fuites cloud
  Overlay légal NON CONFORME sur col 2

- Wave "Onde de transformation" (entre Solution & Pipeline)
  Slider mouse-X interactif : 30 barres SVG morphent rouge → cyan
  Particules tombantes -$/-h (CSS keyframes staggered)
  Étiquettes douleur PAINS / SOLUTIONS flottantes
  Mobile : toggle button, pas de mouse interaction

- Cadre réglementaire "Moniteur d'Interception" (entre Conformité & Témoignages)
  Mappe 6 textes officiels : Loi 25, Loi 96, Cloud Act US, Guide IA Barreau, Cadre IA MCN, CAI
  Liens vers sources autoritaires (legisquebec, congress.gov, barreau, tresor, cai)
  HUD console typing reveal + caret blink + folder QC→US transition
  aria-live="polite" sur verdict, role="list" sur REGS

Texte 100% canonique extrait de Website-Sanity dictai-cycle/wave/contraste.tsx.
Toutes animations CSS pure + Alpine.js + IntersectionObserver natif (zéro lib JS externe).
prefers-reduced-motion désactive tout.
+802 lignes landing.html, +119 lignes tests (6 nouveaux test_round2_*), npm run build:css exécuté.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:21:16 -04:00
Allison
e49652d85d feat(marketing): intègre pipeline 4 étapes + réseau hub d'intégrations depuis dictia.ca/solutions/dictai
- Pipeline (entre Solution & Bento) : Upload → GPU WhisperX → IA Mistral → Export
  Auto-advance Alpine 2400ms, sweep ring SVG CSS, dot glow, prefers-reduced-motion
- Hub (entre Bento & Pricing) : DictIA → 3 hubs → 9 outils
  SVG natif <animateMotion> sur bezier paths, zéro lib JS, fallback liste 3-col WCAG
- Texte 100% canonique extrait de Website-Sanity dictai-pipeline.tsx + dictai-hub.tsx
- OQLF NBSP : "1 heure d'audio → 2 minutes", "5 000+ apps", "100 % en local"
- WCAG : aria-labelledby sections, role=tab/list, focus-visible, prefers-reduced-motion
- +397 lignes, npm run build:css exécuté pour utilities cyan/purple/opacity

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:03:33 -04:00
Allison
aad37f8566 feat(marketing): hero 2-col avec mockup app DictIA animé interactif
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>
2026-04-28 11:45:35 -04:00
Allison
3e56736fa7 feat(marketing): refonte fonctionnalites avec contenu canonique + animations modernes subtiles
7 sections (hero stats counter, sticky sub-nav, 6 fonctionnalités bento avec chips
specs, 3 sous-groupes intégrations, tableau architecture 3 tiers, conformité résumée
+ lien /conformite, CTA final). Contenu canonique extrait du site prod (WhisperX
Large-v3, pyannote-audio, Mistral 7B, RAG sentence-transformers, 8 locuteurs,
30× temps réel, 95%+ FR-CA, prix 3 450/5 750/369 $).

Animations: counter rAF easeOutCubic via Alpine + IntersectionObserver, fade-in
stagger via data-ani-fade, animated underline H2, hover lift cards, sticky sub-nav
avec active highlight, cosmic orbs flottantes, pulse glow sur card recommandée.
Toutes les animations respectent prefers-reduced-motion via media query inline.

Conserve les sections exports/specs/integrations grid pour rétro-compat tests.
13 assertions pytest fonctionnalites passent (les 2 failures conformite sont
pré-existantes sur Windows — mojibake console, non liées à cette refonte).
2026-04-28 11:28:15 -04:00
Allison
48d65c2ab9 refactor(ui): épurer les 3 sections — pas de backdrop tiles, icônes brand-b1 + watermark grad-text
- 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>
2026-04-28 11:04:34 -04:00
Allison
8d50d8ee01 refactor(ui): éliminer tous les emojis (SVG inline + texte propre, look pro/moderne)
Pass de modernisation visuelle : remplacement de TOUS les emojis Unicode dans
les templates marketing/legal/billing/auth par des SVG inline (style heroicons)
ou par du texte propre, pour un look SaaS pro à la Linear/Vercel/Stripe.

Mapping principal :
- ✓ / ✗ / ⚠           → SVG check / x / triangle (text-brand-b3 / red / amber)
- → / ← / ↗            → SVG arrow-right / arrow-left / arrow-up-right
- 🍁 / 🏛️ / ⚖️ / 🔓     → SVG map-pin / building / scale / code-brackets
- 🎙️ / 👥 / 📝 / 💬 / 📄 / 🔌 → 6 SVG bento icons (microphone, users, doc, chat, export, plug)
- ✉️ / ☎️ / 📬          → SVG envelope / phone / map-pin
- ↺                    → SVG refresh-counter-clockwise
- ★                    → SVG star (RECOMMANDÉ badge)
- 🎯/🏢/📺/🤝/📰         → SVG target / office / play / handshake / news (raccourcis contact)
- ⚖️/📊/🏛️ (testimonials) → SVG scale / bar-chart / building
- ✦ (default bento icon) → SVG sparkle inline

Tous les SVG utilisent stroke="currentColor" pour héritage Tailwind text-*.
Les SVG informationnels du tableau comparatif portent un aria-label sémantique
(Conforme/Non conforme/Partiel) ; les SVG décoratifs portent aria-hidden.

Tests :
- 18/18 legal pages passent (test_legal_pages.py)
- test_comparatif_check_marks_consistently_mean_good ajusté pour asserter
  sur les aria-label SVG plutôt que les caractères ✓/✗
- 4 échecs pré-existants non liés (manque /blog dans nav, SOC 2 hedge dans
  conformite.html, gitea.innova-ai.ca url) — confirmés présents avant ce commit

Fichiers modifiés (14) :
- templates/macros/{bento,pricing_card}.html (sources de vérité)
- templates/marketing/{base,_footer,landing,fonctionnalites,tarifs,conformite,contact}.html
- templates/legal/{_layout,index}.html
- templates/billing/{cancel,success}.html
- tests/test_marketing_landing_template.py (assert sur aria-label)

Audit final : 0 emoji restant dans les fichiers in-scope ; 0 emoji dans le
HTML rendu de toutes les pages marketing/legal vérifiées.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:52:58 -04:00
Allison
f83fdfcd68 refactor(ui): V3 fully square buttons + inputs (rounded-none, brutalist/Swiss aesthetic)
V3 finalizes the radii pass to a fully brutalist/Swiss visual language:

- Buttons (CTAs, submit, secondary, ghost, OAuth provider tiles): rounded-none (0px)
- Form inputs (text/email/password/select/textarea/code-entry): rounded-none (0px)
- Checkboxes: rounded-none (0px) — was rounded-sm
- Small icon tiles (w-10 h-10 / w-12 h-12 grad-bg squares): rounded-none (0px)
- Inline code blocks (totp recovery <pre>, secret <code>): rounded-none (0px)
- Cards (pricing, bento, content panels, modals, prev/next nav): rounded (4px) — was rounded-lg
- Alert / flash boxes: rounded (4px) — was rounded-lg
- Pills, badges, status chips, ordres pros avatars, decorative cosmic orbs: rounded-full preserved
- Legal _layout.html inline <style> blockquote/pre/code/draft-callout: border-radius 0 — was 4px

Updated tests/test_marketing_landing_template.py assertions:
- bento icon assertion: "grad-bg rounded " -> "grad-bg rounded-none "
- pricing recommended frame: "rounded-lg" -> "rounded" (with strict trailing-char match to avoid rounded-none false positive)

Verification: 18/18 legal tests pass, 58/58 marketing landing tests pass, 5/5 root redirect tests pass. Two pre-existing failures in test_marketing_secondary_pages (SOC 2 hedge text + gitea.innova-ai.ca URL) are unrelated to this radii pass.
2026-04-28 10:26:51 -04:00
Allison
0b91294c45 refactor(ui): sharpen buttons + inputs to rounded (4px) for modern minimal SaaS look
V2 sharper radii system aligned with Stripe Dashboard / Linear / Vercel
aesthetic — the v1 rounded-lg (8px) on buttons still felt too soft.

New scale:
- Buttons (CTA, submit, ghost, secondary): rounded-lg → rounded (4px)
- Form inputs (text/email/password/select/textarea): rounded-md → rounded (4px)
- Checkboxes: rounded-sm (2px) added explicitly to consent + remember-me
- Cards (pricing, bento, content panels): rounded-xl → rounded-lg (8px)
- Small icon tiles (w-10 h-10 / w-12 h-12 grad-bg squares): rounded-md → rounded
- Pills, badges, avatars, status orbs: rounded-full (KEPT)
- Inline code in legal CSS: rounded (4px) (KEPT)
- Legal blockquote/pre/draft-callout border-radius: 8px → 4px

Files modified (24):
- templates/macros/{button,bento,pricing_card}.html
- templates/marketing/{landing,tarifs,fonctionnalites,conformite,contact}.html
- templates/auth/{check_email,forgot_password,magic_link_request,
  oauth_finish_signup,passkey_setup,reset_password,totp_setup,totp_verify,
  verify_success}.html
- templates/billing/{success,cancel}.html
- templates/legal/{_layout,index}.html
- templates/{register,login}.html
- tests/test_marketing_landing_template.py (assertions updated to match v2)

Verification:
- 18/18 legal page tests pass (tests/_run_legal_pages_windows.py)
- 58/58 marketing landing tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:03 -04:00
Allison
48ff4e70e6 refactor(ui): modernize button + input border radii (rounded-lg/md/xl, sharper SaaS aesthetic)
Aligns DictIA marketing/auth/legal/billing templates with modern SaaS visual
conventions (Linear, Vercel, Stripe Dashboard, Notion). Old radii (12-18px)
felt dated; new system uses 6-12px for tighter, more contemporary corners.

Border radius system:
- Buttons (CTA, submit, secondary): rounded-[0.75rem] (12px) -> rounded-lg (8px)
- Form inputs (text/email/password/select/textarea/checkbox): rounded-[0.5rem] -> rounded-md (6px)
- Cards (pricing, bento, modals, content panels): rounded-[18px]/[14px]/[12px] -> rounded-xl (12px)
- Pricing card outer gradient frame: rounded-[20px] -> rounded-xl (matches inner)
- Pills / badges / status chips: KEEP rounded-full
- Avatars / circular icon containers: KEEP rounded-full
- Code blocks: KEEP rounded (4px)

Decision tree applied for ambiguous cases:
- Button-like clickable CTA -> rounded-lg
- Form input -> rounded-md
- Card / panel / modal -> rounded-xl
- Badge / pill / chip / avatar -> rounded-full (preserved)

In-scope templates modified (23):
- macros/button.html (central macro, cascades to all callers)
- macros/pricing_card.html, macros/bento.html
- marketing/landing.html, tarifs.html, fonctionnalites.html, conformite.html, contact.html
- auth/check_email.html, forgot_password.html, magic_link_request.html, oauth_finish_signup.html,
  passkey_setup.html, reset_password.html, totp_setup.html, totp_verify.html, verify_success.html
- billing/cancel.html, billing/success.html
- legal/_layout.html, legal/index.html
- register.html, login.html

Out of scope (untouched): index.html, account.html, admin.html, share.html, inquire.html,
group-admin.html, components/**, includes/**, modals/** (all legacy Speakr Vue surfaces).

Tests: test_marketing_landing_template.py — 2 assertions updated to match new bento icon
(rounded-md) and pricing card frame (rounded-xl). All 18 legal tests + 58 marketing landing
tests + 9 signup_loi25 tests still pass. Decorative rounded-full preserved on hero cosmic
orbs and ordres-pros avatar circles.

Diff: 94 insertions / 94 deletions (1:1 mechanical replacement, no class drift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:55:13 -04:00
Allison
924d127ab4 feat(legal): polished UX for 5 legal pages + AGPL external link (sticky TOC, prev/next, breadcrumb)
Refonte visuelle et accessibilité (WCAG 2.2 AA) de la section /legal/
sans toucher au contenu juridique signé (dc4ac97).

Templates :
- templates/legal/index.html : grille 6 cartes (5 internes + AGPL externe)
  avec icônes SVG sémantiques, hero gradient, bloc info sous-processeurs,
  carte AGPL ↗ (target=_blank, rel=noopener noreferrer).
- templates/legal/_layout.html : breadcrumb sticky, TOC sticky desktop +
  collapsible mobile (Alpine.js + IntersectionObserver), prev/next nav
  entre les 6 docs, skip link, landmarks (main / aside / nav), typographie
  améliorée (h2 avec accent gradient, tables zebrées, blockquotes), print
  stylesheet (cache header/breadcrumb/TOC/prev-next).

Routes (src/legal/routes.py) :
- DISPLAY_ORDER + EXTERNAL_LINKS + PAGE_ICONS exposés.
- legal_page() calcule prev/next via _neighbour() helper.
- legal_index() concatène pages internes + EXTERNAL_LINKS dans `pages`.

Footer : lien AGPL déjà présent depuis dc4ac97 (col 4 Compte, ligne 49).

Tests (tests/test_legal_pages.py) : 9 anciens + 9 nouveaux = 18/18 PASS
- AGPL external link (target+rel)
- 5 internes + 1 externe sur l'index
- Skip link présent partout
- Prev/next existe sur chaque page
- Conditions (1ère) sans prev / Mentions (dernière) sans next
- Landmarks aside aria-label="Table des matières"
- Landmark main role + id="main-content"
- Breadcrumb avec aria-current="page"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:39:40 -04:00
Allison
dc4ac9754b fix(legal): conform site to signed master legal documents (PDC, CGU, EFVP, DPA)
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>
2026-04-28 09:27:04 -04:00
Allison
e1e31b51fd fix(legal): B-2.9 Loi 25 accuracy — audio retention truth + OQLF + LPRPSP cite
L1 (Loi 25 art. 11 + 23): audio retention row in confidentialite.md now
matches the actual code default (ENABLE_AUTO_DELETION=false,
GLOBAL_RETENTION_DAYS=0). Previous wording falsely claimed audio was
auto-deleted at end of transcription; truth is conservation indéfinie
until manual deletion or admin-configured retention policy.

L3 (OQLF): replaced English "DRAFT v1.0 — pending legal review by
Allison Rioux" with French "BROUILLON v1.0 — en attente de revue
juridique par Allison Rioux" in DRAFT callout of all 6 legal pages
(conditions, confidentialite, cookies, remboursement, accessibilite,
mentions). Required for OQLF compliance on a Quebec-public site.

L5 (LPRPSP cite): replaced shorthand "article 32 de la Loi 25" with
the precise citation "article 32 de la Loi sur la protection des
renseignements personnels dans le secteur privé (LPRPSP, RLRQ c.
P-39.1, telle que modifiée par la Loi 25)" — the exact form CAI uses
in its own correspondence.

All 9 legal page tests still pass (test_legal_pages_have_loi25_draft_callout
matches on either 'draft' or 'allison rioux'; both still present after L3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:06:58 -04:00
Allison
55569366f4 feat(legal): B-2.9 6 pages légales (CGU, Loi 25, cookies, remboursement, accessibilité, mentions)
- 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>
2026-04-28 08:57:36 -04:00
Allison
64738bfd1f feat(billing): B-2.8 Stripe webhook handler (subscription lifecycle + idempotency)
Endpoint: POST /checkout/webhooks/stripe (CSRF-exempt; signature-verified)

Handles 5 Stripe events:
  - checkout.session.completed     -> create Subscription, activate user
  - customer.subscription.updated  -> sync status + current_period_end
  - customer.subscription.deleted  -> mark canceled
  - invoice.payment_succeeded      -> recover from past_due if applicable
  - invoice.payment_failed         -> mark past_due

Idempotency via WebhookEvent table (Stripe ID dedup) and Subscription
unique constraint on stripe_subscription_id (defends against duplicate
deliveries with distinct event IDs).

User resolution prefers stripe_customer_id (server-set, anti-tamper)
over event metadata.dictia_user_id over customer_email (per B-2.7
review note).

New tables created via db.create_all():
  - subscription (FK user.id ondelete=SET NULL for Loi 25 art. 28.1)
  - webhook_event (idempotency ledger)

CSRF exemption wired via src/billing/exempt_webhook_csrf(csrf) called
from src/app.py after billing_bp registration.

Tests: 17/17 pass via tests/_run_stripe_webhook_windows.py.
Existing 25 B-2.7 + 21 TOTP + 22 WebAuthn + 21 OAuth + 16 email tests
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:41:03 -04:00
Allison
f1a5ad565f feat(billing): B-2.7 Stripe Checkout 3 plans CAD/TVQ + Apple/Google Pay
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>
2026-04-28 08:26:13 -04:00
Allison
b8fa321edd feat(auth): B-2.6 WebAuthn / Passkey support (FIDO2 + biometric 2FA)
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>
2026-04-28 00:27:09 -04:00
Allison
aa269c5bc0 feat(auth): B-2.5 TOTP MFA + recovery codes (Fernet-encrypted secret)
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>
2026-04-28 00:08:40 -04:00
Allison
3a41bb482d fix(auth): B-2.4 security review fixes — OAuth linking + magic link replay
Follow-up to commit 0513e67 addressing 2 critical OAuth account-takeover
vulnerabilities and 5 important issues found in the security review.

Critical fixes:
- C1: gate OAuth email-link on ``email_verified is True`` (strict bool)
  in find_user_by_oauth + callback. Hostile Microsoft personal account
  or Workspace tenant returning email_verified=False (or omitting the
  claim) can no longer auto-link to an existing account. Callback shows
  a friendly French flash + redirect to /login when the email exists
  but the IdP didn't verify it.
- C2: refuse to overwrite an existing sso_subject in find_user_by_oauth.
  A second IdP claiming the victim's email (Google after Microsoft, or
  a hostile second Microsoft tenant) now raises PermissionError instead
  of silently re-binding the User row, which would lock the legitimate
  user out. Callback catches and flashes the error message in French.

Important fixes:
- I1: replace ``except Exception: pass`` in init_oauth_providers with an
  idempotency pre-check on _oauth._clients. Real registration errors
  (bad metadata URL, network failure) now surface as exceptions instead
  of being silently swallowed at app boot.
- I2: single-use enforcement for magic-link tokens via in-process JTI
  cache (_consumed_jtis dict). Replay within the 15-min validity window
  now returns None. SECRET_KEY is now strictly required (no
  default-dev-key fallback). Operator-facing comment documents that
  /auth/magic-link/* should also be scrubbed from Cloudflare/Flask
  access logs as defence in depth.
- I3: pre-check email collision in create_oauth_user_with_consent and
  raise dedicated EmailAlreadyExistsError. Race against parallel /signup
  in another tab between OAuth callback and finish-signup POST now
  redirects to /login with a helpful French flash instead of burning 5
  retry attempts and surfacing a 500.
- I4: oauth_signup_pending session blob now carries a created_at
  timestamp; finish-signup rejects sessions older than 15 min with a
  graceful expiry flash + redirect to /login.
- I5: init_oauth_providers logs an INFO when no providers are enabled
  so operators can spot misconfigured deployments.

Tests: 16 → 21 (5 new):
- test_oauth_callback_refuses_link_when_email_not_verified (C1)
- test_oauth_callback_refuses_to_overwrite_existing_sso_subject (C2)
- test_finish_signup_handles_concurrent_account_creation (I3)
- test_finish_signup_expires_stale_oauth_session (I4)
- test_magic_link_token_is_single_use (I2)

Existing tests updated for new contract:
- test_oauth_callback_links_existing_user_by_email now sets
  email_verified=True in the mock token (required by C1 gate).
- test_finish_signup_requires_cgu_and_confidentialite and
  test_finish_signup_creates_user_and_4_consent_logs now seed
  created_at in the session blob (required by I4 expiry check).
- test_magic_link_consume_logs_in_user_with_valid_token now also
  asserts a second consume of the same token returns None and
  redirects to /auth/magic-link with an invalid/expired flash.

Verified: 21/21 OAuth+magic-link tests pass; 16/16 email service tests
still pass (no regression in adjacent surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:50:55 -04:00
Allison
0513e67838 feat(auth): B-2.4 OAuth Microsoft/Google + magic link (Loi 25 deferred consent)
Adds Microsoft 365 + Google OAuth providers (separate from the existing
generic OIDC SSO at src/auth/sso.py) and a passwordless magic-link login
flow. New OAuth signups capture Loi 25 art. 14 consents (4 granular
checkboxes) BEFORE creating the User row via /auth/oauth/finish-signup.

Per compatibility-audit.md C2:
- No src/auth_extended/ directory — extends src/auth/ in place
- No new User columns — reuses sso_provider/sso_subject + email_verified
- Magic-link tokens via itsdangerous URLSafeTimedSerializer (15-min, no DB)
- All routes added to existing auth_bp; templates extend marketing/base.html
- Anti-enumeration on /auth/magic-link (generic flash for unknown OR
  unverified emails) and /auth/magic-link/<token> (same flash for
  invalid/expired/unverified-user)

Files added:
- src/auth/oauth_providers.py — Microsoft + Google OAuth registration,
  is_oauth_provider_enabled(), find_user_by_oauth(), create_oauth_user_with_consent()
- src/auth/magic_link.py — generate/consume magic-link tokens
- templates/auth/magic_link_request.html, templates/auth/oauth_finish_signup.html
- tests/test_oauth_magic_link.py + tests/_run_oauth_magic_link_windows.py (16 tests)
- config/env.oauth.example

Files modified:
- src/api/auth.py — 5 new routes (oauth_provider_login/callback,
  oauth_finish_signup, magic_link_request/consume); login flashes translated FR;
  oauth_*_enabled flags passed to login template
- src/app.py — wires init_oauth_providers(app) after blueprint registration
- src/services/email.py — adds send_magic_link_email() (FR + DictIA brand)
- templates/login.html — refondu IN PLACE (was 178 lines legacy Vue/TW3)
  to extend marketing/base.html with OAuth buttons, password form,
  magic-link CTA, signup link
- templates/auth/check_email.html — adds action='magic_link' branch
- static/css/tailwind.config.js — adds templates/login.html to content
- static/css/marketing.css — rebuilt

Tests: 16/16 PASS via Windows manual driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:29:24 -04:00
Allison
dd270bca9e fix(auth): B-2.3 security review fixes — XSS escape + token replay
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 &#160; for NBSP instead of '1&nbsp;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>
2026-04-27 23:14:11 -04:00
Allison
37639a7d09 feat(auth): B-2.3 emails FR + DictIA branding (SMTP Resend)
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>
2026-04-27 23:02:20 -04:00
Allison
3b324ad0b9 fix(auth): B-2.2 review fixes — Tailwind path + WCAG + race + flash + tests
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.
2026-04-27 22:43:00 -04:00
Allison
d2fc1f03ed feat(auth): B-2.2 signup Loi 25-compliant (4 consent checkboxes)
Refondre /register en /signup avec consentement granulaire (LPRPSP art. 14):
- SignupLoi25Form (Flask-WTF) remplace RegistrationForm
- 4 BooleanField séparés: cgu, confidentialite (obligatoires) + marketing,
  analytics (optionnels). Chaque consentement crée 1 row ConsentLog avec
  ip_address (CF-Connecting-IP > remote_addr), user_agent (tronqué 500),
  version='2026-04-27' (B-2.9 substituera LEGAL_VERSION canonique).
- Marketing/analytics non cochés -> ConsentLog row avec granted=False
  (refus explicite tracé pour audit Loi 25).
- /register reste 302 -> /signup (backward compat).
- Username auto-généré unique depuis email local-part (max 20, alphanum,
  suffixe numérique sur collision).
- name = "{first_name} {last_name}".strip() persisté dans User.name
  (pas de colonnes first_name/last_name au modèle).
- send_verification_email() existant réutilisé (smtplib via env SMTP_*).

Template register.html refondu IN PLACE pour étendre marketing/base.html:
- 4 checkboxes dans <fieldset>+<legend>, AUCUNE pré-cochée
- WCAG 2.2 AA: focus-visible outlines, aria-required, label for=, role=alert
- OQLF: NBSP via | safe pour "Loi&nbsp;25"

Tests: 9 cas couvrent GET 200, refus CGU, refus RPRP, happy path 4 rows,
capture IP+UA, duplicate email, username collision, /register redirect,
CSRF enforcement. Pattern test_consent_log.py (no conftest, env setup
avant imports, app_context, db.create_all/drop_all).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:29:12 -04:00
Allison
8792ffb8a4 fix(auth): B-2.1 — FK erasure policy, totp_secret_encrypted, validates, docs
- ConsentLog.user_id: nullable=True + ondelete='SET NULL' for Loi 25 art. 28.1
  right-to-erasure (audit row survives user deletion, user_id nulled out).
  Matches existing pattern in auth_log.py / access_log.py.
- Add ConsentLog.@validates('consent_type') to reject typos at ORM level
  (silent typos in audit data are very hard to detect later).
- Rename User.totp_secret -> totp_secret_encrypted (size 64->255 for Fernet
  envelope). Self-documenting contract: never assign plaintext to this column.
- init_db.py: drop NOT NULL from totp_enabled migration string for consistency
  with every other Boolean column in the file (model-side nullable=False is
  sufficient).
- Docs: User class docstring updated to reflect MFA/billing/ordre context;
  webauthn_credentials shape documented; version column policy documented.
- Tests: cleaner IntegrityError catch; add survives_user_deletion test
  (right-to-erasure); add rejects_invalid_consent_type test (validator).
2026-04-27 21:57:32 -04:00
Allison
48d2abfa74 feat(auth): B-2.1 ConsentLog model (Loi 25) + User MFA/OAuth/Stripe fields
- New src/models/consent.py — ConsentLog with user_id FK, consent_type
  ('cgu' | 'confidentialite' | 'marketing' | 'analytics'), version, granted
  bool, granted_at/revoked_at timestamps, ip_address (45 chars for IPv6),
  user_agent (500 chars). User.consent_logs backref. Audit trail per
  LPRPSP art. 14 (consent tracé) + art. 3.5 (journal).
- src/models/user.py: add 7 new columns (totp_secret, totp_enabled DEFAULT 0,
  webauthn_credentials JSON, ordre_pro, cabinet, stripe_customer_id,
  subscription_status). Do NOT duplicate existing sso_provider/sso_subject/
  email_verified/etc. (per compatibility-audit C4).
- src/init_db.py: 7 add_column_if_not_exists() calls for the new User
  columns + 2 create_index_if_not_exists() for stripe_customer_id and
  subscription_status. NO Alembic — init_db.py pattern matches
  compatibility-audit C3.
- src/models/__init__.py: register ConsentLog import.
- tests/test_consent_log.py: 7 tests — grant flow, 4 consent types, revoke
  preserves audit trail, User backref, NOT NULL on ip/UA, User.B-2.1 fields
  round-trip, defaults safe.
2026-04-27 21:44:37 -04:00
Allison
d45c9c9349 fix(marketing): A-2.8b — Loi 25 badge contrast (WCAG AA) + stale docstring
- 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>
2026-04-27 21:37:05 -04:00
Allison
3646a5e64d feat(marketing): A-2.8b /conformite + /contact standalone pages
- /conformite page: extends base.html, page H1 with cosmic orb header,
  4 pillar cards on white (mirrors landing's Conformite section content
  with same hedges 'Mappe' 'concue avec' 'Compatible'), 3 Loi 25 article
  detail cards (art. 3.3 EFVP, art. 3.5 Audit trail, art. 14 Consentement)
  with grad-bg article-number badges, AGPL v3 transparency CTA section
  with external links to Gitea + gnu.org (rel=noopener), generic CTA section
- /contact page: extends base.html, 3 method cards (email, phone tel:link,
  postal address with <address>), 6 pre-filled mailto subject shortcuts
  with focus-visible WCAG 2.2 AA, pre-launch disclaimer that online form
  ships at launch (B-2.x). NO <form> tag - mailto only - POST returns 405
  until B-2.x adds the form handler.
- routes.py: add /conformite and /contact routes; preserves existing
  landing/tarifs/fonctionnalites views and TESTIMONIALS/FAQ data.
- tests: append 13 new tests to test_marketing_secondary_pages.py covering
  routes 200, single H1, 4 pillars + Loi 25 articles + AGPL externals on
  /conformite, 3 contact methods + 6 shortcuts + 405 on POST + pre-launch
  note + OQLF typography on /contact.
- Apply established WCAG 2.2 AA, FlexiHub, OQLF, LPC art. 219 disciplines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:23:47 -04:00
Allison
202e1a08d9 fix(marketing): A-2.8a — extract pricing partial + sync bento + OQLF + test calibration
- 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&eacute;b&eacute;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&eacute;silie' -> 'résilie' (Jinja outputs raw UTF-8, not entities)
- Update src/marketing/routes.py module docstring to reflect 2/4 done.
2026-04-27 21:06:26 -04:00
Allison
d471626183 feat(marketing): A-2.8a /tarifs + /fonctionnalites standalone pages
- /tarifs page: extends base.html, reuses pricing_card + button macros,
  shows the 3 forfaits with NBSP-formatted CAD prices, an 8-row deep-dive
  comparison matrix (DictIA 8 vs DictIA 16 vs DictIA Cloud), 5 tarification
  FAQ items (frais cachés, migration, GPU, taxes TPS/TVQ, plans annuels)
  with Alpine accordion + focus-visible WCAG 2.2 AA, CTA section
- /fonctionnalites page: extends base.html, reuses bento_card macro,
  re-renders the 6 features with same content as landing's bento section
  for consistency, adds dedicated 7-format export grid + 8-integration
  grid (with trademark disclaimer) + 6 tech specs section (Whisper/pyannote
  /Mistral/stack/audio/langues), CTA section
- routes.py: add /tarifs and /fonctionnalites routes (passes FAQ to /tarifs
  for the tarification accordion; preserves existing landing(), TESTIMONIALS,
  FAQ data structures unchanged)
- tests/test_marketing_secondary_pages.py: NEW test file (16 tests covering
  routes 200, base.html inheritance, H1 anchors, 3 pricing cards, comparison
  matrix, tarifs FAQ accordion, OQLF typography, 6 bento + 7 exports + 8
  integrations + 6 tech specs sections, canonical meta)
- All sections respect WCAG 2.2 AA, FlexiHub design discipline, LPC art. 219
  hygiene (sourcing dates, trademark disclaimer, hedged claims, NBSP)
2026-04-27 20:50:07 -04:00
Allison
2b3eeb98e0 fix(marketing): A-2.7b WCAG 2.2 AA polish + JSON-LD test hardening
- 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)
2026-04-27 20:34:53 -04:00
Allison
824ea638de feat(marketing): A-2.7b témoignages placeholder + FAQ accordion + CTA + JSON-LD
- Pre-launch testimonials section: 3 placeholder cards (avocat, CPA, municipal)
  with persona icons + 'Témoignage à venir' label — NO fabricated quotes
  (LPC art. 219). Expected publication mai-juin 2026 from T-4.1 interviews.
- FAQ accordion: 7 verifiable Q&A using Alpine.js core (x-data + x-show +
  built-in x-transition; NO x-collapse plugin). Each item has @click toggle,
  :aria-expanded, aria-controls, role="region" panel, focusable button.
- Schema.org FAQPage JSON-LD inline at end of FAQ section — striptags +
  replace('&nbsp;', ' ') to normalize entities for Google FAQ rich result.
- CTA final: 'Réservez votre pré-inscription' (mailto + #tarifs anchor),
  cosmic orbs to mirror Hero (page closure), ghost variant secondary button.
- Inline TESTIMONIALS and FAQ Python lists in src/marketing/routes.py
  (no PyYAML dep — YAGNI; T-4.1 can introduce it when real data warrants).
- 8 new tests covering testimonials placeholders, forbidden fake names,
  7 FAQ panels, Alpine bindings, JSON-LD schema, CTA wording, route data.
2026-04-27 19:52:36 -04:00
Allison
31fada46d4 fix(marketing): A-2.7a — comparatif consistency + SOC 2 hedge + cross-platform emoji
- 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>
2026-04-27 19:36:01 -04:00
Allison
0d69fcd034 feat(marketing): A-2.7a footer 4-col + comparatif table + conformité 4 pillars
- Replace placeholder _footer.html with full 4-column footer (Brand, Produit,
  Légal, Compte) — added aria-labelledby landmark, sr-only headings, address
  block, tel: link, external-link new-tab announcements, NBSP per OQLF
- Add Comparatif section: DictIA vs MS Teams vs Otter.ai vs Whisper local
  on 6 criteria. Sourced (politiques publiques + grilles officielles 2026-04-27),
  trademark disclaimer, responsive table with sr-only caption + scope attrs
- Add Conformité forteresse section: 4 pillars (OVH Beauharnois, mapped Loi 25
  LPRPSP refs, compatible Cadre IA LGGRI, AGPL v3 verifiable Gitea link)
- Soft hedges throughout: 'mappé', 'conçu avec', 'compatible' (no 'certifié',
  'endossé', 'reconnu' — LPC art. 219 / Competition Act s. 52 hygiene)
- 11 new tests covering footer landmarks, comparatif sourcing, conformité
  hedges, WCAG /70 contrast, and forbidden competitive claim regressions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:21:39 -04:00
Allison
7d67b64ddc fix(marketing): pricing — honest ROI payback + capped sliders + URL hygiene
- 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>
2026-04-27 19:05:36 -04:00
Allison
0ae4053faa feat(marketing): pricing 3 forfaits + ROI calculator Alpine.js 2026-04-27 18:50:33 -04:00
Allison
b87f35ea4a fix(marketing): bento autoescape + dead col-span + test gaps
- Pipe macro title/description through | safe to render NBSP/&amp; correctly
  (autoescape was producing literal '95&nbsp;%+' and 'Q&amp;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
2026-04-27 18:19:56 -04:00
Allison
775075d1ea feat(marketing): bento grid 6 features (style FlexiHub) 2026-04-27 18:03:57 -04:00
Allison
7c6c6fd433 fix(marketing): PAS section legal calibration + NBSP typography consistency
- I-1: soften 'Sanctions disciplinaires' card 3 from categorical
  '9 ordres considèrent' (implies unanimous formal positions) to
  attributive 'au premier rang desquels Barreau, CNQ, CPA' + 'peut être
  qualifié de manquement' + 'pour les fautes graves' (proportional)
- I-2: '252 000+ pros' → '~250 000 pros (CIQ)' — defensive margin +
  cite CIQ (Conseil interprofessionnel du Québec) as source
- M-2: NBSP entities now applied across Problème section ('Loi 25',
  '25 M\$', '4 %', '250 000') for OQLF typography consistency with
  Solution section. |safe filter on card variables to render entities.
2026-04-27 17:52:15 -04:00
Allison
3c471a72d1 feat(marketing): PAS frame sections (Problème + Solution) after trust bar
- Problème section (light bg-brand-bg, white cards): 3 risk cards
  (Cloud Act, Loi 25 biométrie art. 60.1 LPRPSP, sanctions disciplinaires
  9 ordres pros). H2 grad-text accent on 'violent la Loi 25' — defensible
  legal claim citing CAI + LPRPSP statutes by name. text-brand-navy/70 for
  all body text (WCAG AA compliant, no /40 or /50 regression).
- Solution section (bg-brand-navy with single subtle green orb): 3 pillars
  (100% local, Conforme Loi 25, Précision FR-CA). H2 grad-text accent on
  'par design'. Pillars cite specific tech (WhisperX Large-v3, Mistral 7B,
  pyannote, OVH Beauharnois, AGPL v3) — all factually verifiable.
- French typography: NBSP via &nbsp; + |safe before %, $, and within
  'Loi 25' to prevent line break separation (OQLF rule).
- 4 new tests verify both sections, 3 cards each, key tech mentions,
  and WCAG-safe opacity policy.
2026-04-27 17:42:46 -04:00
Allison
54168e443b fix(marketing): trust bar accuracy + WCAG AA contrast + LPC art. 219 hygiene
- Critical (C1): align 9-ordre list with dictia.ca canonical (Barreau, CNQ,
  CPA, ChAD, OACIQ, CMQ, OIIQ, OPQ, OEQ). Drop ambiguous OPPQ; replace M/P
  short monograms with disambiguated 3-5 char abbreviations (BAR, CNQ, CPA,
  ChAD, OACIQ, CMQ, OIIQ, OPQ, OEQ). Tooltips show full disambiguating names.
- Critical (C2): raise text-brand-navy/40 -> /70 on footnote (2.69:1 -> 9:1
  contrast, passes WCAG AAA) and text-[10px] navy/50 -> text-xs navy/70 on
  monogram captions (12px minimum + AA contrast). Critical for legal
  disclosure legibility.
- Critical (C3): drop unverifiable '50 heures' specific number from
  methodology footnote — replaced with 'methodologie disponible sur demande'
  (defensible without committing to numbers we can't verify).
- Important (I1): use &nbsp; before %/$ in KPI numbers per OQLF French
  typography rules + |safe filter to render entities.
- Important (I2): replace fragile substring-strip test with explicit
  forbidden-phrase list (RECONNU PAR, ENDOSSÉ PAR, etc.). Update ordre
  list test + footnote test to match new wording.
2026-04-27 17:35:43 -04:00
Allison
2a7e142b03 feat(marketing): trust bar with 9 ordres pros + 4 KPIs + methodology footnote
- Section AFTER hero, white bg with brand-border y-borders
- 9 monogram placeholders (gradient circles with initials, NOT official
  logos to avoid licensing issues + false-endorsement exposure)
  hover from opacity-50 to opacity-100 for subtle interaction
- Eyebrow phrasing 'MAPPÉ AUX 9 ORDRES PROFESSIONNELS' (factual scope,
  not 'CERTIFIÉ PAR' which would be a false-endorsement claim under
  LPC art. 219 / Competition Act s. 52)
- 4 KPIs with grad-text numbers: ~5 min/heure, 95%+ FR-CA, 0$ par user,
  100% local — each with a 1-line context line and a small subtext
- Methodology footnote: 'Précision mesurée sur 50 heures d'audio
  interne, détails sur demande' — defensible disclosure for the 95%
  claim (LPC art. 219 hygiene)
- 4 new tests verify ordres list, factual phrasing, KPIs, footnote
2026-04-27 17:27:03 -04:00
Allison
b24a0f064d fix(marketing): WCAG 2.3.3 reduced-motion + defensible social proof + em-spacing
- Add @media (prefers-reduced-motion: reduce) override to disable animations
  for vestibular-sensitive users (covers hero + future scroll/infinite anims)
- Replace 5-star icon + '27 cabinets' claim with shield + 'Conçu avec 9
  ordres professionnels' + 'Pré-inscription ouverte' (LPC art. 219 +
  Competition Act s. 52 compliance — pre-launch claims must be factual)
- Convert H1/H2/H3 letter-spacing from absolute px to em-relative (-3px →
  -0.028em on H1) so tracking scales correctly with clamp font-size on mobile
- Update test_hero_has_social_proof_microcopy to assert new copy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:19:16 -04:00
Allison
03af2a516d feat(marketing): hero section with cosmic orbs + dual CTA + social proof
- 3 radial-gradient orbs (blue 16%, cyan 7%, green 11%) + subtle 40px
  grid + horizontal accent line — FlexiHub cosmic background signature
- H1 clamp(2.5rem,4vw,4rem) font-black with grad-text accent on
  'sans risquer votre permis' tagline
- Eyebrow 11px tracking-[0.18em] grad-text — 'TRANSCRIPTION IA · CONFORME LOI 25 · QUÉBEC'
- Sub-headline ≤25 words declares Loi 25 compliance + 9 ordres pros
- Dual CTA: Réserver une démo (primary gradient+glow) + Voir les tarifs
  → (ghost) — drives demo conversion + pricing self-service
- Social proof microcopy above-the-fold: 5★ + 27 cabinets + Lancement
  printemps 2026
- Staggered tc-fade-in-up animations 0/75/150/300/400ms with
  animation-fill-mode: backwards (no FOIT)
- 6 new tests verify H1 grad-text, dual CTA, orb opacities, social proof,
  staggered delays, eyebrow messaging
2026-04-27 17:13:02 -04:00
Allison
89e2fd29d1 fix(marketing): button macro safety + placeholder assets + mobile login
- Button macro: variants/sizes use .get() with primary/md fallback (no KeyError),
  add as_button=True parameter for form submit CTAs (forms in B-2.2 signup,
  B-2.7 checkout)
- Header: drop hidden sm:inline-block on Connexion link so login is always
  visible from mobile (avoids UX dead-end before A-2.7 hamburger lands)
- Add placeholder static/images/favicon.svg (brand gradient + 'D')
- Add placeholder 1x1 PNG static/images/og/og-default.png (replaced in A-3.3
  with real 1200x630 branded OG image)
2026-04-27 17:02:15 -04:00
Allison
49bf94576c feat(marketing): base.html layout + glassmorphism header + button macro
- templates/macros/button.html: 3 variants (primary gradient/glow, secondary,
  ghost) x 3 sizes for reuse across marketing/billing/legal/auth templates
- templates/marketing/base.html: Tailwind v4-scoped layout with FlexiHub
  glassmorphism header (62px, navy/.97, backdrop-blur-xl, .045 border),
  sticky positioning, OG/Twitter meta, Inter font preload, marketing.css
  link, Alpine.js defer, 5-item main nav + Connexion/Demarrer CTAs
- templates/marketing/_footer.html: minimal Phase 2 placeholder with
  legal links + Inverness QC address + info@dictia.ca (full footer in A-2.7)
- templates/marketing/landing.html: minimal hero placeholder (replaced
  in A-2.2 with full hero + cosmic orbs)
- src/marketing/routes.py: landing() now render_template instead of inline HTML
- 7 tests verify template structure, FlexiHub markers, nav, CTAs, legal
  links, no login redirect for anonymous users
- Tailwind CSS rebuilt with new template content scope (cssnano-minified)
2026-04-27 16:51:06 -04:00
Allison
08318a946f fix(marketing): RFC 9309 robots.txt semantics + lazy marketing import
- Remove redundant named User-agent blocks (Googlebot, Bingbot, ClaudeBot,
  GPTBot, PerplexityBot, Applebot) that per RFC 9309 §2.2 overrode the
  wildcard and granted those bots access to /api/, /admin, /account.
- Add explicit Google-Extended and ChatGPT-User blocks (AI opt-in
  signaling) with full Allow/Disallow rule sets.
- Fix /blog → /blog/ for prefix-match consistency.
- Move src.marketing.routes import inside recordings.index() function
  to localize cross-blueprint dependency (was at module top, inverting
  initialization order).
- Add shadow-warning comment at marketing_bp registration site.
2026-04-27 16:43:02 -04:00
Allison
af2953995c fix(marketing): call marketing.landing view directly (avoid redirect loop)
recordings.index previously redirected anonymous users to
url_for('marketing.landing'), but both endpoints are mounted at '/'.
Since recordings_bp registers first, Flask's URL map routed back to
recordings.index -> infinite redirect loop. Now we invoke the marketing
landing view function directly for anonymous requests, preserving the
URL map and avoiding the loop.
2026-04-27 16:31:31 -04:00
Allison
1071e56173 feat(marketing): exempt public blueprints from noindex + fix / route collision
- add_no_crawl_headers now skips marketing.*, legal.*, billing.success,
  static, and robots_txt endpoints via _is_public_indexable_endpoint
  helper; all other routes keep the X-Robots-Tag noindex header
- recordings.index drops @login_required and instead redirects
  anonymous users to marketing.landing, resolving the URL-map
  collision between recordings_bp and marketing_bp at "/"
- robots.txt rewritten: public marketing pages and /legal/* allowed,
  /api/, /admin, /account, /share/, /app/, /checkout, /login, /signup,
  /webhooks/ disallowed; Googlebot, Bingbot, ClaudeBot, GPTBot,
  PerplexityBot, Applebot explicitly allowed
- New tests/test_no_crawl_headers.py (14 tests) covers exemption
  helper + integration on /, /robots.txt, /static, /admin, /login
- New tests/test_marketing_root_redirect.py (4 tests) verifies
  anonymous users at / never get a /login redirect

Tests verified via AST + logic walkthrough; pytest blocked on Windows
by pre-existing fcntl import in src/init_db.py (B-1.2 limitation).
2026-04-27 16:28:55 -04:00
Allison
55ae09431d fix(marketing): add template_folder + tighten blueprint registration tests
- Explicit template_folder on marketing/billing/legal blueprints prevents silent
  template fallback in Phase 2
- Replace vacuous test assertions (len>=0, substring '/' in r) with direct
  url_prefix and exact-match route checks (per code review I-1, I-2, I-3)
2026-04-27 16:21:34 -04:00
Allison
e01523125e feat(marketing): register 3 new Flask blueprints (marketing, billing, legal)
- marketing_bp at root "/"
- billing_bp at /checkout/* (routes added in B-2.7)
- legal_bp at /legal/* (routes added in B-2.9)
- Tests verify all 3 blueprints register correctly
- Coexists with existing recordings_bp at "/" (resolved in B-1.3)
2026-04-27 16:15:55 -04:00
Allison
accd9ebf36 chore(deps): pin cryptography in constraints.txt for reproducibility
resend SDK omitted from B-1.1 per audit D.2 — using existing SMTP path
in src/services/email.py. markdown==3.5.1 left as-is (3.6 non-breaking).
2026-04-27 16:01:12 -04:00
Allison
b1a84135e2 feat(marketing): add Stripe, MFA, qrcode, markdown deps 2026-04-27 15:57:08 -04:00
Allison
2e2f343520 chore: declare *.min.js binary + Alpine.js MIT in NOTICE 2026-04-27 15:52:17 -04:00
Allison
571890e692 feat(marketing): self-host Alpine.js 3 2026-04-27 15:47:23 -04:00
Allison
191711c4d9 chore: declare font binary types + OFL font attribution in NOTICE 2026-04-27 15:45:06 -04:00
Allison
3ca542fe40 feat(marketing): self-host Inter Variable + JetBrains Mono (Loi 25) 2026-04-27 15:40:52 -04:00
Allison
31948aec01 fix(marketing): enable cssnano minification + npm ci for reproducibility 2026-04-27 15:32:05 -04:00
Allison
b27b3c1d44 feat(marketing): bootstrap Tailwind v4 + design tokens FlexiHub
Adds Tailwind v4 / PostCSS pipeline that compiles to static/css/marketing.css,
to be loaded only by future templates/marketing/** templates and to coexist
with the existing legacy v3 JIT runtime used by index.html / account.html /
admin.html. The legacy v3 runtime stays untouched.

- package.json: postcss-cli build:css and watch:css scripts
- postcss.config.js: @tailwindcss/postcss + autoprefixer
- static/css/tailwind.config.js: brand tokens (b1/b2/b3, navy, navy2, navy3,
  bg, border), font families, brand-grad, cta shadows, FlexiHub keyframes
  (tc-fade-in-up/right, tc-float-y, tc-pulse-glow, plus-breathe). content
  paths scoped to marketing/legal/billing/macros only - purge cannot touch
  legacy templates.
- static/css/input.css: @import "tailwindcss"; + @config directive (Tailwind
  v4 backward-compat for v3-style JS config). @font-face Inter Variable +
  JetBrains Mono Variable (woff2). base layer body font/color, h1-h3
  letter-spacing. utilities: grad-text, grad-bg, eyebrow.
- Dockerfile: new stage 3 'assets-builder' (node:20-alpine) compiles CSS;
  runtime stage copies the built file in via --from=assets-builder, after
  COPY . . so the freshly-built file always wins.
- .gitignore + .dockerignore: exclude node_modules.

Build verified locally: marketing.css = 121 KB minified (Tailwind v4 ships
all default theme tokens + reset properties even with empty content;
realistic baseline, will not grow much as marketing markup is added).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:23:25 -04:00
109 changed files with 28613 additions and 956 deletions

View File

@@ -48,6 +48,7 @@ tests/
.claude/ .claude/
.migrate/ .migrate/
.github/ .github/
node_modules/
# IDE and editor files # IDE and editor files
.idea/ .idea/

6
.gitattributes vendored
View File

@@ -26,4 +26,10 @@ docker-compose*.yml text eol=lf
*.webp binary *.webp binary
*.db binary *.db binary
*.pyc binary *.pyc binary
*.min.js binary
*.woff2 binary
*.woff binary
*.ttf binary
*.otf binary
*.eot binary

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.playwright-mcp/ .playwright-mcp/
venv/ venv/
node_modules/
__pycache__/ __pycache__/
instance/ instance/
uploads/ uploads/
@@ -26,6 +27,7 @@ changes.txt
!deployment/**/*.md !deployment/**/*.md
!client_docs/**/*.md !client_docs/**/*.md
!client_docs/*.md !client_docs/*.md
!src/legal/content/*.md
docker-compose.dev.yml docker-compose.dev.yml
docker-compose.lite.yml docker-compose.lite.yml
docker-compose.postgres.yml docker-compose.postgres.yml

View File

@@ -43,7 +43,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends wget xz-utils \
&& rm -rf /tmp/ff.tar.xz /tmp/ffmpeg-dir && rm -rf /tmp/ff.tar.xz /tmp/ffmpeg-dir
############################################################################### ###############################################################################
# Stage 3: Runtime — lean final image with only what's needed # Stage 3: Assets builder — compile Tailwind v4 marketing CSS
###############################################################################
FROM node:20-alpine AS assets-builder
WORKDIR /app
COPY package.json postcss.config.js ./
COPY static/css ./static/css
COPY templates ./templates
RUN npm ci --no-audit --no-fund && NODE_ENV=production npm run build:css
###############################################################################
# Stage 4: Runtime — lean final image with only what's needed
############################################################################### ###############################################################################
FROM python:3.11-slim FROM python:3.11-slim
@@ -62,6 +72,9 @@ COPY --from=builder /app/static/vendor /app/static/vendor
# Copy application code # Copy application code
COPY . . COPY . .
# Copy compiled marketing CSS (overrides any stale file from `COPY . .`)
COPY --from=assets-builder /app/static/css/marketing.css /app/static/css/marketing.css
# Create necessary directories # Create necessary directories
RUN mkdir -p /data/uploads /data/instance && chmod 755 /data/uploads /data/instance RUN mkdir -p /data/uploads /data/instance && chmod 755 /data/uploads /data/instance

7
NOTICE
View File

@@ -3,3 +3,10 @@ Copyright (C) 2026 InnovA AI
AGPL-3.0 — voir LICENSE AGPL-3.0 — voir LICENSE
Oeuvre originale: github.com/murtaza-nasir/speakr (C) 2024-2026 Murtaza Nasir Oeuvre originale: github.com/murtaza-nasir/speakr (C) 2024-2026 Murtaza Nasir
Bundled fonts (SIL OFL 1.1):
- Inter Variable v4.1 — © 2016 The Inter Project Authors (https://github.com/rsms/inter)
- JetBrains Mono Variable v2.304 — © 2020 JetBrains s.r.o. (https://github.com/JetBrains/JetBrainsMono)
Bundled JavaScript (MIT):
- Alpine.js v3.15.11 — © Caleb Porzio (https://alpinejs.dev)

View File

@@ -14,25 +14,25 @@ ENABLE_EMAIL_VERIFICATION=false
REQUIRE_EMAIL_VERIFICATION=false REQUIRE_EMAIL_VERIFICATION=false
############################################################################### ###############################################################################
# SMTP Configuration # SMTP Configuration (Resend recommended for DictIA — Loi 25 compliant via DKIM/SPF/DMARC)
############################################################################### ###############################################################################
# SMTP server hostname (required for email functionality) # SMTP server hostname (required for email functionality)
# Examples: smtp.gmail.com, smtp.sendgrid.net, smtp.mailgun.org # DictIA default: Resend SMTP relay (https://resend.com)
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.resend.com
# SMTP server port # SMTP server port
# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted) # Common ports: 587 (TLS/STARTTLS), 465 (SSL), 2587 (alt-TLS)
# Default: 587 # Default: 587
SMTP_PORT=587 SMTP_PORT=587
# SMTP authentication username (usually your email address) # SMTP authentication username
SMTP_USERNAME=your-email@gmail.com # For Resend: literal "resend"
SMTP_USERNAME=resend
# SMTP authentication password # SMTP authentication password
# For Gmail: Use an App Password (not your regular password) # For Resend: an API key from https://resend.com/api-keys (starts with "re_")
# https://support.google.com/accounts/answer/185833 SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx
SMTP_PASSWORD=your-app-password
# Use TLS/STARTTLS encryption (recommended for port 587) # Use TLS/STARTTLS encryption (recommended for port 587)
# Default: true # Default: true
@@ -44,17 +44,27 @@ SMTP_USE_TLS=true
SMTP_USE_SSL=false SMTP_USE_SSL=false
# Email address that appears in the "From" field # Email address that appears in the "From" field
# Should be a valid email address, ideally matching your domain # Domain MUST be verified in your Resend dashboard (DKIM + SPF + DMARC)
SMTP_FROM_ADDRESS=noreply@yourdomain.com # Canonical for DictIA: noreply@dictia.ca
SMTP_FROM_ADDRESS=noreply@dictia.ca
# Display name that appears alongside the from address # Display name that appears alongside the from address
# Default: Speakr # Default: DictIA
SMTP_FROM_NAME=Speakr SMTP_FROM_NAME=DictIA
############################################################################### ###############################################################################
# Provider-Specific Examples # Provider-Specific Examples
############################################################################### ###############################################################################
# --- Resend (recommended for DictIA — TLS, DKIM/SPF/DMARC, Cloudflare-friendly) ---
# SMTP_HOST=smtp.resend.com
# SMTP_PORT=587
# SMTP_USE_TLS=true
# SMTP_USERNAME=resend
# SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx # Get from https://resend.com/api-keys
# SMTP_FROM_ADDRESS=noreply@dictia.ca # Domain MUST be verified in Resend dashboard
# SMTP_FROM_NAME=DictIA
# --- Gmail --- # --- Gmail ---
# SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587 # SMTP_PORT=587
@@ -104,6 +114,6 @@ SMTP_FROM_NAME=Speakr
# Security Recommendations: # Security Recommendations:
# - Always use TLS or SSL encryption # - Always use TLS or SSL encryption
# - Use app-specific passwords when available (Gmail, etc.) # - Use app-specific passwords or API keys when available (Resend, Gmail, etc.)
# - Consider using a dedicated email service (SendGrid, Mailgun, SES) # - For DictIA: prefer Resend (DKIM/SPF/DMARC handled, Loi 25-friendly logs in EU)
# - Set a strong SECRET_KEY in your Flask configuration # - Set a strong SECRET_KEY in your Flask configuration

101
config/env.oauth.example Normal file
View File

@@ -0,0 +1,101 @@
###############################################################################
# OAuth Providers — Microsoft 365 + Google (B-2.4)
###############################################################################
#
# These providers complement (do NOT replace) the generic OIDC SSO at
# config/env.sso.example. Both can be enabled simultaneously: users see
# Microsoft 365, Google, and SSO buttons on /login, plus the magic-link
# fallback that does not require any OAuth provider.
#
# IMPORTANT — Loi 25 art. 14 (consent must be granular, free, informed):
# OAuth signups still require Loi 25 consent capture via
# /auth/oauth/finish-signup BEFORE the User row is created. Existing
# users (matched by sso_subject or email) skip the consent page and log
# in directly.
#
# Magic-link login (/auth/magic-link, /auth/magic-link/<token>) reuses
# the SMTP settings from env.email.example — no additional env vars needed.
###############################################################################
# Microsoft 365 (Microsoft Entra ID, formerly Azure AD)
###############################################################################
# 1. Register a new app at https://entra.microsoft.com
# > Identity > Applications > App registrations > New registration
# 2. Set the redirect URI to:
# https://your-domain.example/auth/oauth/microsoft/callback
# 3. Generate a client secret under Certificates & secrets > Client secrets
# 4. Set MS_CLIENT_ID to the Application (client) ID
# 5. Set MS_CLIENT_SECRET to the secret VALUE (NOT the secret ID)
#
# Tenant restriction: by default the OAuth flow accepts users from any
# Microsoft tenant (server_metadata_url uses /common/). To restrict to a
# specific organization, edit src/auth/oauth_providers.py and replace
# /common/ with your tenant ID (e.g. /your-tenant-id-guid/).
#
# MS_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# MS_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
###############################################################################
# Google (Google Cloud Console)
###############################################################################
# 1. Create an OAuth client at https://console.cloud.google.com
# > APIs & Services > Credentials > Create Credentials > OAuth client ID
# Application type: "Web application"
# 2. Set the redirect URI to:
# https://your-domain.example/auth/oauth/google/callback
# 3. Configure the OAuth consent screen in the same console
# (must be in "Production" status to accept users outside the test list)
# 4. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the credentials page
#
# GOOGLE_CLIENT_ID=xxxxxxxxxxxx-xxxxxxxxxxxx.apps.googleusercontent.com
# GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
###############################################################################
# Notes
###############################################################################
#
# Token storage:
# - sso_provider stores the literal string "microsoft" or "google"
# - sso_subject stores the OAuth `sub` claim (provider-issued user ID)
# - email_verified is set to True automatically (the provider has
# already verified the email address)
# - password is NULL for OAuth-only accounts; users can set a password
# later via /forgot-password if they want a fallback login method
#
# Magic-link tokens:
# - Stateless via itsdangerous.URLSafeTimedSerializer
# - 15-minute expiry, signed with SECRET_KEY + salt 'magic-link-login'
# - No DB column — tokens are not single-use within the 15-min window
# - SMTP must be configured (see env.email.example) for the link to send
###############################################################################
# WebAuthn / Passkey (B-2.6)
###############################################################################
# Phishing-resistant 2nd factor via FIDO2 hardware keys (YubiKey etc.) and
# device biometrics (Touch ID, Windows Hello). Browsers strictly enforce that
# the values below match the page making the WebAuthn API call:
#
# - WEBAUTHN_RP_ID : the registrable host name (NO scheme, NO port). Must
# match the eTLD+1 of the page or be a parent domain. For dictia.ca use
# 'dictia.ca'; for staging at app.staging.dictia.ca use 'dictia.ca' or
# 'staging.dictia.ca'. Defaults to 'localhost' for local development.
#
# - WEBAUTHN_RP_NAME : the display name shown to the user inside their
# authenticator's prompt (e.g. 'Sign in to DictIA'). Defaults to 'DictIA'.
#
# - WEBAUTHN_ORIGIN : the FULL origin including scheme + host + optional
# port. MUST equal window.location.origin on the client side. Mismatches
# are rejected by the browser before the request even reaches the server.
# Defaults to 'http://localhost:8899' for local development.
#
# Credentials are persisted in user.webauthn_credentials (JSON column,
# added in B-2.1). Each credential dict contains base64url id, public_key,
# sign_count (anti-cloning per WebAuthn §6.1.1), transports, name, and
# created_at. The 4 JSON endpoints (register/begin, register/finish,
# auth/begin, auth/finish) are CSRF-exempt at Flask-WTF level because
# CSRFProtect cannot read tokens from a JSON body without app-wide config.
# An X-CSRFToken header is still sent by the client as defence-in-depth.
#
# WEBAUTHN_RP_ID=dictia.ca
# WEBAUTHN_RP_NAME=DictIA
# WEBAUTHN_ORIGIN=https://dictia.ca

87
config/env.stripe.example Normal file
View File

@@ -0,0 +1,87 @@
###############################################################################
# Stripe — Checkout + Subscriptions (B-2.7 / B-2.8) — v7.0 pricing
###############################################################################
#
# Required for the /checkout/<plan> flow and the /webhooks/stripe receiver.
# The application will boot without these — billing routes will redirect to
# /tarifs with a "contact info@dictia.ca" message until the keys are set.
#
# Get these from https://dashboard.stripe.com (CAD account)
# - Use sk_test_/pk_test_/whsec_test_ keys against the Stripe test mode for
# pre-prod. Switch to live keys ONLY after end-to-end CAD/TVQ rehearsal.
# STRIPE_SECRET_KEY=sk_test_... # or sk_live_...
# STRIPE_PUBLISHABLE_KEY=pk_test_... # used client-side; not strictly needed for hosted Checkout
# STRIPE_WEBHOOK_SECRET=whsec_... # for B-2.8 webhook signature verification
###############################################################################
# Price IDs — v7.0 (Cloud Basic / Essentiel / Pro + DictIA Local)
###############################################################################
#
# Format: price_xxxxxxxxxxxxxxxxxxxxxxxxxx
# Naming convention in this codebase: STRIPE_<PLAN>_<TYPE>
# PLAN = CLOUD_BASIC | CLOUD_ESSENTIEL | CLOUD_PRO | DICTIA_LOCAL
# TYPE = SETUP (one-time) | MONTHLY | YEARLY | RENEWAL_YEARLY (DictIA Local An 2+)
#
# Yearly Price = Monthly Price × 12 × 0.85 (15 % discount). Configure both
# Prices in the Stripe Dashboard for each Cloud plan.
# Pro+ is quote-only — NO Stripe Price IDs (the route redirects to /contact).
# Cloud BASIC : 189 $/mo (no setup) — solopreneur, petite équipe, ~165 h audio/mo
# STRIPE_CLOUD_BASIC_MONTHLY=price_xxx
# STRIPE_CLOUD_BASIC_YEARLY=price_xxx
# Cloud ESSENTIEL : 349 $/mo (no setup) — cabinet en croissance, ~330 h audio/mo
# STRIPE_CLOUD_ESSENTIEL_MONTHLY=price_xxx
# STRIPE_CLOUD_ESSENTIEL_YEARLY=price_xxx
# Cloud PRO : 549 $/mo + 485 $ onboarding (one-time) — usage intensif, ~660 h audio/mo
# STRIPE_CLOUD_PRO_SETUP=price_xxx
# STRIPE_CLOUD_PRO_MONTHLY=price_xxx
# STRIPE_CLOUD_PRO_YEARLY=price_xxx
# DictIA LOCAL : 5 998 $ An 1 (one-time matériel + 1ère année logiciel) puis 500 $/an dès An 2
# STRIPE_DICTIA_LOCAL_SETUP=price_xxx
# STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY=price_xxx
###############################################################################
# Required Stripe Dashboard configuration
###############################################################################
#
# 1. Activate CAD currency on the account (Settings → Account → Currencies).
#
# 2. Enable Stripe Tax with TPS (5 %) and TVQ (9.975 %) for Quebec
# (Tax → Settings → Tax registrations → Canada → Quebec).
# All Checkout Sessions are created with `automatic_tax: { enabled: true }`
# and `billing_address_collection: required` so Stripe computes taxes.
#
# 3. Enable Apple Pay + Google Pay
# (Settings → Payment methods → Apple Pay, Google Pay).
# Apple Pay requires verifying the dictia.ca domain via the Stripe-hosted
# `.well-known/apple-developer-merchantid-domain-association` file.
#
# 4. For each Cloud plan, create:
# - One recurring monthly Price (CAD, billing_scheme=per_unit)
# - One recurring yearly Price (CAD, = monthly × 12 × 0.85)
# For Cloud PRO, also create a one-time Price for the 485 $ setup fee.
# For DictIA LOCAL, create:
# - One one-time Price for 5 998 $ (An 1 — matériel + logiciel)
# - One recurring yearly Price for 500 $ (renewal — MAJ + support dès An 2)
#
# 5. Create a webhook endpoint (B-2.8) pointing at
# https://your-domain.example/checkout/webhooks/stripe
# (the route lives under the /checkout/* prefix; CSRF-exempt; signature-
# verified via STRIPE_WEBHOOK_SECRET below).
#
# Subscribe at minimum to these 5 events (the only ones the handler
# processes; all others are acknowledged with 200 + ignored):
# - checkout.session.completed (creates Subscription row, sets
# User.subscription_status='active')
# - customer.subscription.updated (status / current_period_end sync)
# - customer.subscription.deleted (marks status='canceled')
# - invoice.payment_succeeded (renewal touch; recovers past_due)
# - invoice.payment_failed (marks status='past_due')
#
# Copy the signing secret (whsec_...) into STRIPE_WEBHOOK_SECRET above.
# Without that secret, the webhook endpoint returns 400 invalid_signature
# on every delivery (Stripe will retry for up to 30 days).

View File

@@ -1 +1,2 @@
scipy<1.15 scipy<1.15
cryptography==47.0.0

2457
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "dictia-marketing",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"build:css": "postcss static/css/input.css -o static/css/marketing.css",
"watch:css": "postcss static/css/input.css -o static/css/marketing.css --watch"
},
"devDependencies": {
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.41",
"postcss-cli": "^11.0.0",
"autoprefixer": "^10.4.19",
"cssnano": "^7.0.0"
}
}

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
...(process.env.NODE_ENV === 'production' ? { cssnano: { preset: 'default' } } : {}),
}
}

View File

@@ -23,3 +23,9 @@ numpy==1.24.3
scikit-learn==1.3.0 scikit-learn==1.3.0
scipy<1.15 scipy<1.15
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
# Marketing redesign 2026 deps
stripe==7.14.0
pyotp==2.9.0
webauthn==2.5.2
qrcode==7.4.2

File diff suppressed because it is too large Load Diff

View File

@@ -1357,8 +1357,32 @@ def reset_status(recording_id):
@recordings_bp.route('/') @recordings_bp.route('/')
@login_required
def index(): def index():
"""Root route handler.
Anonymous users see the marketing landing page so the public site is
reachable at "/". Authenticated users continue to see the recordings
dashboard (legacy Speakr UI).
Phase 1 of marketing redesign 2026 (B-1.3) replaced the previous
@login_required decorator with this inline check to resolve the route
collision between recordings_bp.index and marketing_bp.landing.
NOTE: We invoke the marketing.landing view function directly (rather
than redirecting via url_for('marketing.landing')) because both
endpoints are mounted at "/". Since recordings_bp is registered first,
Flask's URL map resolves "/" to recordings.index, so a redirect would
loop back into this same handler indefinitely.
The src.marketing.routes import is lazy (inside the function) on
purpose: it localizes the cross-blueprint dependency to the call
site rather than coupling recordings_bp module load to marketing_bp
module load, preserving the apparent initialization order in app.py.
"""
if not current_user.is_authenticated:
from src.marketing.routes import landing as _marketing_landing
return _marketing_landing()
# Check if user is a group admin # Check if user is a group admin
is_team_admin = GroupMembership.query.filter_by( is_team_admin = GroupMembership.query.filter_by(
user_id=current_user.id, user_id=current_user.id,

View File

@@ -585,6 +585,11 @@ from src.api.api_v1 import api_v1_bp, init_api_v1_helpers
from src.api.audit import audit_bp from src.api.audit import audit_bp
from src.api.docs import docs_bp from src.api.docs import docs_bp
# Marketing redesign 2026 blueprints (Phase 1: B-1.2)
from src.marketing import marketing_bp
from src.billing import billing_bp
from src.legal import legal_bp
# Database initialization (extracted to src/init_db.py) # Database initialization (extracted to src/init_db.py)
from src.init_db import initialize_database from src.init_db import initialize_database
with app.app_context(): with app.app_context():
@@ -632,6 +637,28 @@ csrf.exempt(api_v1_bp) # API v1 uses token auth, not CSRF
app.register_blueprint(audit_bp) app.register_blueprint(audit_bp)
app.register_blueprint(docs_bp) app.register_blueprint(docs_bp)
# Marketing redesign 2026 blueprints (Phase 1: B-1.2)
# - marketing_bp at "/" (placeholder; coexists with recordings_bp.index, resolved in B-1.3)
# - billing_bp at /checkout/* (routes added in B-2.7 and B-2.8)
# - legal_bp at /legal/* (routes added in B-2.9)
# NOTE: marketing_bp.landing at "/" is shadowed by recordings.index (registered
# earlier above). recordings.index dispatches anonymous users to landing() directly.
app.register_blueprint(marketing_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(legal_bp)
# B-2.8: CSRF-exempt the Stripe webhook (signature-verified server-to-server).
# Must be called AFTER billing_bp is registered (so the view function exists
# in app.view_functions) and AFTER csrf is initialized (already done above).
from src.billing import exempt_webhook_csrf as _exempt_webhook_csrf
_exempt_webhook_csrf(csrf)
# Initialize Microsoft + Google OAuth providers (B-2.4) — no-op if env vars absent.
# Must run AFTER blueprints are registered (Authlib's OAuth object needs to be
# attached to the running app instance).
from src.auth.oauth_providers import init_oauth_providers as _init_oauth_providers
_init_oauth_providers(app)
# File monitor and scheduler initialization functions below # File monitor and scheduler initialization functions below
# Startup functions (extracted to src/config/startup.py) # Startup functions (extracted to src/config/startup.py)
@@ -641,12 +668,40 @@ from src.config.startup import initialize_file_monitor, get_file_monitor_functio
run_startup_tasks(app) run_startup_tasks(app)
# --- No-Crawl System: HTTP Headers --- # --- No-Crawl System: HTTP Headers ---
# Endpoints that must remain indexable by search engines and AI crawlers.
# Public marketing/legal/billing-success pages are exempted from the
# X-Robots-Tag noindex header so they can be discovered (Loi 25 transparency,
# GEO/SEO strategy). All other routes (api, admin, account, share, app, auth,
# recordings dashboard, etc.) keep the noindex header as defense-in-depth.
_PUBLIC_INDEXABLE_PREFIXES = ('marketing.', 'legal.')
_PUBLIC_INDEXABLE_ENDPOINTS = frozenset({
'billing.success', # post-payment confirmation page (added in B-2.7)
'robots_txt', # served from /robots.txt
'static', # static asset serving
})
def _is_public_indexable_endpoint(endpoint):
"""Return True if the resolved endpoint should NOT receive noindex headers."""
if not endpoint:
return False
if endpoint in _PUBLIC_INDEXABLE_ENDPOINTS:
return True
return endpoint.startswith(_PUBLIC_INDEXABLE_PREFIXES)
@app.after_request @app.after_request
def add_no_crawl_headers(response): def add_no_crawl_headers(response):
""" """
Add HTTP headers to discourage search engine crawling and indexing. Add HTTP headers to discourage search engine crawling and indexing.
This provides defense-in-depth alongside robots.txt and meta tags. This provides defense-in-depth alongside robots.txt and meta tags.
Marketing pages, legal pages, and the post-payment success page are
exempted so they remain indexable by search engines and AI crawlers.
""" """
if _is_public_indexable_endpoint(request.endpoint):
return response
response.headers['X-Robots-Tag'] = 'noindex, nofollow, noarchive, nosnippet, noimageindex' response.headers['X-Robots-Tag'] = 'noindex, nofollow, noarchive, nosnippet, noimageindex'
return response return response

105
src/auth/magic_link.py Normal file
View File

@@ -0,0 +1,105 @@
"""Magic link login (B-2.4).
Stateless tokens via ``itsdangerous`` (no DB column). Same pattern as
``src/services/email.py:generate_verification_token`` — token contains
the user_id; ``max_age`` is 15 minutes.
The compatibility-audit (C2) explicitly forbids new User columns
(no ``magic_link_token``, no ``magic_link_sent_at``). Single-use
enforcement is implemented at the application layer via an in-process
JTI cache (see ``_consumed_jtis`` below) — within a single gunicorn
worker, a token can be consumed exactly once. Cross-worker uniqueness
in a multi-worker deployment is best-effort and would require Redis or
a small DB table; with the route's 10/min rate limit this is acceptable
for B-2.4.
OPERATOR NOTE — log scrubbing:
The magic-link token appears in the URL path (``/auth/magic-link/<token>``)
and will therefore be captured by Cloudflare access logs, Flask's request
log, and the user's browser history. The single-use cache here mitigates
replay-from-logs within the 15-minute validity window, but operators
should ALSO scrub ``/auth/magic-link/*`` from log retention as defence
in depth (the operator action is documented in the security review;
no application-side fix can fully address logs that have already been
written elsewhere).
"""
import secrets
import time
from typing import Optional
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from flask import current_app
MAGIC_LINK_EXPIRY_SECONDS = 15 * 60 # 15 minutes
_SALT = 'magic-link-login'
# In-process consumed-JTI cache: {jti: expires_at_unix_timestamp}.
# Single-use enforcement against replay within the 15-min validity window.
# Cache is best-effort: in a multi-worker gunicorn deployment a JTI
# consumed on worker A would still be accepted on worker B. For production
# multi-worker deployments, replace with Redis or a small DB table.
# For B-2.4 with rate-limiting at 10/min on consume + 5/min on request,
# this provides meaningful single-use enforcement within a worker.
_consumed_jtis: dict = {}
def _serializer() -> URLSafeTimedSerializer:
"""Build a fresh serializer per call (cheap; reads SECRET_KEY from app config).
Raises:
RuntimeError: if SECRET_KEY is missing from app config. We refuse
to fall back to a default key because that would let anyone
forge magic-link tokens against any deployment that forgot
to set SECRET_KEY.
"""
secret_key = current_app.config.get('SECRET_KEY')
if not secret_key:
raise RuntimeError(
"SECRET_KEY must be configured for magic-link tokens"
)
return URLSafeTimedSerializer(secret_key, salt=_SALT)
def _purge_expired_jtis() -> None:
"""Drop entries past their expiry to bound memory."""
now = time.time()
for jti in [j for j, exp in _consumed_jtis.items() if exp < now]:
_consumed_jtis.pop(jti, None)
def generate_magic_link_token(user_id: int) -> str:
"""Generate a single-use magic-link token (15-min expiry, includes random JTI).
The JTI (JSON Token ID) is a random 16-byte URL-safe string embedded
in the token payload. On consume, the JTI is added to the in-process
``_consumed_jtis`` cache; subsequent consumes of the same token
return None (single-use enforcement).
"""
jti = secrets.token_urlsafe(16)
return _serializer().dumps({'uid': user_id, 'jti': jti})
def consume_magic_link_token(token: str) -> Optional[int]:
"""Verify + mark token as consumed. Returns user_id once; None on
replay/expired/invalid/malformed.
Single-use enforcement: the JTI is added to ``_consumed_jtis`` on
success; a second call with the same token returns None.
"""
try:
payload = _serializer().loads(token, max_age=MAGIC_LINK_EXPIRY_SECONDS)
except (SignatureExpired, BadSignature):
return None
if not isinstance(payload, dict):
return None
user_id = payload.get('uid')
jti = payload.get('jti')
if not isinstance(user_id, int) or not isinstance(jti, str):
return None
_purge_expired_jtis()
if jti in _consumed_jtis:
return None # replay — token already consumed
_consumed_jtis[jti] = time.time() + MAGIC_LINK_EXPIRY_SECONDS
return user_id

281
src/auth/oauth_providers.py Normal file
View File

@@ -0,0 +1,281 @@
"""Microsoft 365 + Google OAuth providers (B-2.4).
Adds two named OAuth clients alongside the existing generic SSO at
``src/auth/sso.py``. Patterns match sso.py: env-var gated, separate
OAuth instance from the generic SSO, but with **Loi 25 consent capture
deferred** to ``/auth/oauth/finish-signup`` for new users (existing
users by sso_subject or email skip the consent page and log in directly).
The compatibility-audit (C2) explicitly forbids creating an
``src/auth_extended/`` directory or new User columns — we reuse
``User.sso_provider`` (max 100) and ``User.sso_subject`` (max 255, unique)
to store the provider name (``'microsoft'`` | ``'google'``) and the OAuth
``sub`` claim.
"""
import os
from typing import Dict, Optional
from authlib.integrations.flask_client import OAuth
from src.database import db
from src.models import User
# Single OAuth instance shared across providers — kept separate from
# src/auth/sso.py's _oauth (which serves the legacy generic SSO).
_oauth: Optional[OAuth] = None
# Provider configuration — server_metadata_url + scope baseline.
_PROVIDER_CONFIG = {
'microsoft': {
'env_client_id': 'MS_CLIENT_ID',
'env_client_secret': 'MS_CLIENT_SECRET',
'server_metadata_url': (
'https://login.microsoftonline.com/common/v2.0/'
'.well-known/openid-configuration'
),
'scope': 'openid email profile',
'display_name': 'Microsoft 365',
},
'google': {
'env_client_id': 'GOOGLE_CLIENT_ID',
'env_client_secret': 'GOOGLE_CLIENT_SECRET',
'server_metadata_url': (
'https://accounts.google.com/.well-known/openid-configuration'
),
'scope': 'openid email profile',
'display_name': 'Google',
},
}
class EmailAlreadyExistsError(Exception):
"""Raised by create_oauth_user_with_consent when email is already taken
between the OAuth callback (where the new-user check passed) and the
finish-signup POST (where the User row is finally inserted).
This protects against a race: a parallel /signup in another tab can
create a User with the same email between callback and finish-signup,
making the OAuth User insert fail with an IntegrityError on the
email-unique constraint. Catching this allows a graceful flash + redirect
instead of a 500.
"""
def is_oauth_provider_enabled(provider: str) -> bool:
"""Return True if the provider has client_id AND client_secret in env."""
cfg = _PROVIDER_CONFIG.get(provider)
if cfg is None:
return False
return bool(os.environ.get(cfg['env_client_id'])) and bool(
os.environ.get(cfg['env_client_secret'])
)
def get_oauth_provider_display_name(provider: str) -> str:
"""User-facing label for the provider (Microsoft 365 / Google)."""
cfg = _PROVIDER_CONFIG.get(provider)
return cfg['display_name'] if cfg else provider
def init_oauth_providers(app) -> Optional[OAuth]:
"""Register Microsoft + Google OAuth clients. Idempotent — call once at startup.
Returns the OAuth instance, or None if no provider is enabled.
"""
global _oauth
enabled_providers = [p for p in _PROVIDER_CONFIG if is_oauth_provider_enabled(p)]
if not enabled_providers:
# Operability: log when no providers are enabled so operators don't
# silently lose OAuth login on misconfigured deployments.
app.logger.info(
'OAuth providers: none enabled (set MS_CLIENT_ID/MS_CLIENT_SECRET '
'or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable).'
)
return None
if _oauth is None:
_oauth = OAuth(app)
for provider in enabled_providers:
cfg = _PROVIDER_CONFIG[provider]
# Idempotent: skip re-registration if already registered (Authlib caches
# by name in `_clients`). Real registration errors (bad metadata URL,
# network failure) now surface as exceptions instead of being silently
# swallowed by a bare `except Exception: pass`.
if provider in getattr(_oauth, '_clients', {}):
app.logger.debug(
'OAuth provider %r already registered (skipping)', provider
)
continue
_oauth.register(
name=provider,
client_id=os.environ[cfg['env_client_id']],
client_secret=os.environ[cfg['env_client_secret']],
server_metadata_url=cfg['server_metadata_url'],
client_kwargs={'scope': cfg['scope']},
)
app.logger.info(
'OAuth providers initialized: %s', ', '.join(enabled_providers)
)
return _oauth
def get_oauth_client(provider: str):
"""Return the OAuth client for `provider`, or raise if not enabled."""
if _oauth is None or not is_oauth_provider_enabled(provider):
raise RuntimeError(f"OAuth provider {provider!r} is not enabled")
return getattr(_oauth, provider)
def find_user_by_oauth(
provider: str,
subject: str,
email: Optional[str],
email_verified: bool,
) -> Optional[User]:
"""Lookup an existing user by sso_subject, then email (link path).
Args:
provider: 'microsoft' or 'google'.
subject: OAuth ``sub`` claim — stable per (IdP, user) tuple.
email: OAuth ``email`` claim (case-insensitive).
email_verified: MUST be True (the literal boolean) for the
email-link branch to fire. Caller is responsible for reading
``userinfo.get('email_verified') is True`` — we treat anything
else as untrusted.
Returns:
- User object: known account (login directly).
- None: brand-new account (caller defers to finish-signup) OR the
email matched an existing account but ``email_verified is not True``
(caller should refuse to silently link — see oauth callback handler).
Raises:
PermissionError: if an existing email-matched user already has a
``sso_subject`` set (linked to a different OAuth identity). Refusing
to overwrite protects against account-hijack via a second IdP
claiming the victim's email (C2 from the security review).
Security notes:
- Linking by email is gated on ``email_verified is True``. A hostile
IdP that returns ``email_verified=False`` (or omits the claim) does
NOT auto-link to an existing account. This blocks the takeover
vector where an attacker creates a Microsoft personal account or
Workspace tenant claiming a victim's mailbox without verification.
- We refuse to overwrite an existing ``sso_subject``. If Alice is
already linked to ms-sub-A, a second login claiming the same email
from google or another tenant is rejected, not silently re-linked.
"""
user = User.query.filter_by(sso_subject=subject, sso_provider=provider).first()
if user:
return user
if email:
existing_email_user = User.query.filter_by(email=email.lower().strip()).first()
if existing_email_user:
# C1: refuse to auto-link if the IdP did not assert email_verified.
# The caller will refuse to fall through to finish-signup either
# (since that would create a duplicate account on a different
# identity), so returning None here triggers the friendly flash.
if email_verified is not True:
return None
# C2: refuse to overwrite an existing linked OAuth identity.
# If we got here the first branch (sso_subject lookup) didn't
# match — meaning either the user has a different sso_subject
# (account hijack attempt) or no sso_subject at all (legit link).
if existing_email_user.sso_subject:
raise PermissionError(
f"L'adresse {email} est déjà liée à une autre identité fédérée. "
f"Connectez-vous avec votre fournisseur d'origine, ou contactez le support."
)
existing_email_user.sso_provider = provider
existing_email_user.sso_subject = subject
db.session.commit()
return existing_email_user
return None
def create_oauth_user_with_consent(
provider: str,
subject: str,
userinfo: Dict[str, str],
consents: Dict[str, bool],
ip: str,
ua: str,
legal_version: str,
) -> User:
"""Create a new User from OAuth claims AFTER Loi 25 consents are granted.
Used by the ``/auth/oauth/finish-signup`` POST handler — never call from
the OAuth callback (consent capture must precede User row creation per
Loi 25 art. 14).
Always writes 4 ConsentLog rows (one per consent_type), recording
explicit refusal as ``granted=False`` for the audit trail.
Raises:
ValueError: if userinfo is missing the email claim.
EmailAlreadyExistsError: if a User with this email already exists
(race against /signup or another OAuth login between the
callback and the finish-signup POST). Caller should handle
with a friendly French flash + redirect to /login.
"""
from src.models.consent import ConsentLog
from src.auth.sso import generate_unique_username
from sqlalchemy.exc import IntegrityError
email = (userinfo.get('email') or '').lower().strip()
if not email:
raise ValueError('OAuth userinfo missing email')
# I3: pre-check for the email-collision race. The username retry loop
# below ONLY helps with username collisions; a duplicate email would
# burn 5 attempts and then re-raise IntegrityError, which surfaces as
# a 500. Detect it once here and raise the dedicated exception so the
# caller can render a friendly "compte existe déjà" flash.
existing = User.query.filter_by(email=email).first()
if existing:
raise EmailAlreadyExistsError(
f"Account with email {email} already exists; cannot create via "
f"OAuth signup. User should sign in with their original method "
f"or contact support."
)
name = (userinfo.get('name') or '').strip()
if not name:
first = (userinfo.get('given_name') or '').strip()
last = (userinfo.get('family_name') or '').strip()
name = f'{first} {last}'.strip()
preferred_username = email.split('@', 1)[0]
max_attempts = 5
user = None
for attempt in range(max_attempts):
username = generate_unique_username(preferred_username)
user = User(
username=username,
email=email,
password=None,
sso_provider=provider,
sso_subject=subject,
name=name or None,
email_verified=True, # OAuth provider already verified the email
)
db.session.add(user)
try:
db.session.flush()
break
except IntegrityError:
db.session.rollback()
if attempt == max_attempts - 1:
raise
# 4 ConsentLog rows — one per Loi 25 consent_type (granular, art. 14).
for ctype in ('cgu', 'confidentialite', 'marketing', 'analytics'):
db.session.add(ConsentLog(
user_id=user.id,
consent_type=ctype,
version=legal_version,
granted=bool(consents.get(ctype, False)),
ip_address=ip,
user_agent=ua,
))
db.session.commit()
return user

184
src/auth/totp.py Normal file
View File

@@ -0,0 +1,184 @@
"""TOTP MFA service layer (B-2.5).
Encrypts the base32 TOTP secret with Fernet (SECRET_KEY-derived key) before
DB persistence. NEVER store the raw base32 secret in the database.
Recovery codes: 10 single-use base32 codes (10 chars each, hyphenated for
readability) generated at TOTP enrollment, displayed ONCE to the user, stored
as bcrypt hashes in User.totp_recovery_codes (JSON list). Each successful
recovery-code login removes that hash from the list.
"""
import base64
import hashlib
import secrets
from typing import List, Optional, Tuple
import pyotp
import qrcode
from cryptography.fernet import Fernet, InvalidToken
from flask import current_app
# 10 single-use recovery codes per enrollment
RECOVERY_CODES_COUNT = 10
RECOVERY_CODE_LENGTH = 10 # base32 chars per code, formatted as XXXXX-XXXXX
def _fernet() -> Fernet:
"""Derive a Fernet key from app SECRET_KEY (deterministic, single-key).
Uses SHA-256 of SECRET_KEY → urlsafe-base64 (32 bytes) so the same
SECRET_KEY always produces the same Fernet key. Single-key design (no
rotation): rotating SECRET_KEY invalidates ALL stored TOTP secrets,
forcing every user to re-enroll. Acceptable for MVP; revisit when we
have key-rotation infra.
"""
secret_key = current_app.config.get('SECRET_KEY')
if not secret_key:
raise RuntimeError('SECRET_KEY must be configured to use TOTP encryption')
if isinstance(secret_key, str):
secret_key = secret_key.encode('utf-8')
derived = hashlib.sha256(secret_key).digest()
return Fernet(base64.urlsafe_b64encode(derived))
def encrypt_totp_secret(plaintext_base32: str) -> str:
"""Encrypt a base32 TOTP secret. Returns the Fernet token as a string."""
if not plaintext_base32 or not isinstance(plaintext_base32, str):
raise ValueError('encrypt_totp_secret requires a non-empty base32 string')
return _fernet().encrypt(plaintext_base32.encode('ascii')).decode('ascii')
def decrypt_totp_secret(ciphertext: str) -> str:
"""Decrypt a Fernet-encrypted TOTP secret. Returns the base32 string.
Raises ValueError on bad token (key mismatch, tampered, malformed).
"""
if not ciphertext:
raise ValueError('decrypt_totp_secret requires a non-empty ciphertext')
try:
return _fernet().decrypt(ciphertext.encode('ascii')).decode('ascii')
except InvalidToken as e:
raise ValueError(f'Invalid TOTP ciphertext (key mismatch?): {e}') from e
def generate_totp_secret() -> str:
"""Generate a fresh base32 TOTP secret (160-bit, RFC 6238 recommended)."""
return pyotp.random_base32()
def build_provisioning_uri(secret_base32: str, account_email: str) -> str:
"""Return the otpauth:// URI for QR encoding (RFC 6238)."""
return pyotp.TOTP(secret_base32).provisioning_uri(
name=account_email, issuer_name='DictIA'
)
def render_qr_data_url(provisioning_uri: str) -> str:
"""Render the URI as a base64 PNG data URL for inline display in HTML."""
import io
img = qrcode.make(provisioning_uri)
buf = io.BytesIO()
img.save(buf, format='PNG')
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('ascii')
def verify_totp_code(secret_base32: str, code: str) -> bool:
"""Verify a 6-digit TOTP code with a 1-window tolerance (current ±30s).
Rejects non-digit / wrong-length input early to avoid leaking timing.
"""
if not code or not isinstance(code, str):
return False
code = code.strip()
if len(code) != 6 or not code.isdigit():
return False
return pyotp.TOTP(secret_base32).verify(code, valid_window=1)
def _get_bcrypt():
"""Resolve the Flask-Bcrypt extension instance (handles missing context)."""
bcrypt = (
current_app.extensions.get('flask-bcrypt')
or current_app.extensions.get('bcrypt')
)
if bcrypt is None:
# Fall back to the global bcrypt initialised by init_auth_extensions()
from src.api.auth import bcrypt as _b
bcrypt = _b
if bcrypt is None:
raise RuntimeError('Flask-Bcrypt extension is not initialised')
return bcrypt
def generate_recovery_codes() -> Tuple[List[str], List[str]]:
"""Generate 10 fresh recovery codes.
Returns (display_codes, hashed_codes_for_storage):
- display_codes: human-readable XXXXX-XXXXX format, shown to user ONCE
- hashed_codes_for_storage: bcrypt-style hashes — store these in
User.totp_recovery_codes (JSON list).
"""
bcrypt = _get_bcrypt()
display_codes: List[str] = []
hashed_codes: List[str] = []
for _ in range(RECOVERY_CODES_COUNT):
# 5+5 base32 chars hyphenated for readability (XXXXX-XXXXX)
raw = base64.b32encode(secrets.token_bytes(7)).decode('ascii')[:RECOVERY_CODE_LENGTH]
display = f'{raw[:5]}-{raw[5:]}'
display_codes.append(display)
# Hash the hyphenated form (what the user will type back) with bcrypt
hashed = bcrypt.generate_password_hash(display).decode('ascii')
hashed_codes.append(hashed)
return display_codes, hashed_codes
def consume_recovery_code(user, candidate: str) -> bool:
"""Check `candidate` against the user's stored recovery code hashes.
On match: removes that hash from the list and commits. Returns True.
On mismatch: returns False, no DB write. Single-use: a code that
matched once will not match again.
"""
from src.database import db
bcrypt = _get_bcrypt()
if not candidate or not user.totp_recovery_codes:
return False
candidate = candidate.strip().upper()
remaining: List[str] = []
matched = False
for h in user.totp_recovery_codes:
if not matched and bcrypt.check_password_hash(h, candidate):
matched = True
# Drop this hash from the stored list (single-use)
continue
remaining.append(h)
if matched:
user.totp_recovery_codes = remaining
db.session.commit()
return matched
def set_user_totp(user, secret_base32: str, recovery_code_hashes: List[str]) -> None:
"""Persist the encrypted secret + recovery codes; mark MFA enabled."""
from src.database import db
user.totp_secret_encrypted = encrypt_totp_secret(secret_base32)
user.totp_recovery_codes = recovery_code_hashes
user.totp_enabled = True
db.session.commit()
def disable_user_totp(user) -> None:
"""Disable MFA: clear encrypted secret, recovery codes, and the flag."""
from src.database import db
user.totp_secret_encrypted = None
user.totp_recovery_codes = None
user.totp_enabled = False
db.session.commit()
def get_user_totp_secret(user) -> Optional[str]:
"""Return the decrypted base32 secret, or None if MFA not enrolled."""
if not user.totp_secret_encrypted:
return None
return decrypt_totp_secret(user.totp_secret_encrypted)

241
src/auth/webauthn.py Normal file
View File

@@ -0,0 +1,241 @@
"""WebAuthn / Passkey service layer (B-2.6).
Wraps the python-webauthn==2.5.2 library to provide:
- Registration options + verification (post-login enrollment)
- Authentication options + verification (used during /2fa/verify)
- Credential persistence in User.webauthn_credentials (JSON column)
Each credential dict stored is:
{
'id': str (base64url, <= 1023 chars by RFC),
'public_key': str (base64url-encoded COSE key),
'sign_count': int,
'transports': list[str] (e.g., ['usb', 'nfc', 'ble', 'internal']),
'name': str (user-supplied label, e.g., 'YubiKey 5C'),
'created_at': str (ISO 8601 UTC),
}
Anti-cloning: every successful authentication updates sign_count from the
authenticator's monotonic counter (RFC 8809). A regression would indicate
a cloned authenticator and is rejected by python-webauthn at verify time.
"""
import base64
import json
import os
from datetime import datetime, timezone
from typing import Dict, List, Tuple
from webauthn import (
generate_registration_options,
generate_authentication_options,
verify_registration_response,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
# --- RP / origin configuration -------------------------------------------------
def get_rp_id() -> str:
"""Relying Party ID — host name only (no scheme, no port).
For dictia.ca this is 'dictia.ca'. Browsers strictly enforce that the
RP ID matches the registrable domain of the page making the request,
so this MUST be configured per-environment.
"""
return os.environ.get('WEBAUTHN_RP_ID', 'localhost')
def get_rp_name() -> str:
"""Display name shown in the user's authenticator UI."""
return os.environ.get('WEBAUTHN_RP_NAME', 'DictIA')
def get_expected_origin() -> str:
"""Full origin (scheme + host + optional port).
MUST match window.location.origin on the client side. Browsers reject
auth if these do not match. Example: 'https://dictia.ca'.
"""
return os.environ.get('WEBAUTHN_ORIGIN', 'http://localhost:8899')
# --- base64url helpers (no padding, RFC 4648 §5) -------------------------------
def _b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
def _b64url_decode(s: str) -> bytes:
pad = '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
# --- Stored credential accessors -----------------------------------------------
def list_user_credentials(user) -> List[Dict]:
"""Return the user's stored credentials (or empty list if None)."""
return user.webauthn_credentials or []
def has_passkeys(user) -> bool:
"""True iff the user has at least one registered WebAuthn credential."""
return bool(list_user_credentials(user))
# --- Registration --------------------------------------------------------------
def begin_registration(user) -> Tuple[dict, str]:
"""Generate registration options for a new credential.
Returns (json-safe options dict, challenge_b64url). Caller stores the
challenge in session keyed by user_id; passes it back to
finish_registration() for verification. Existing credentials are listed
in `excludeCredentials` so the authenticator can refuse to re-enroll
something it has already provisioned for this user.
"""
existing = list_user_credentials(user)
exclude = [
PublicKeyCredentialDescriptor(id=_b64url_decode(c['id']))
for c in existing
]
options = generate_registration_options(
rp_id=get_rp_id(),
rp_name=get_rp_name(),
user_id=str(user.id).encode('utf-8'),
user_name=user.email,
user_display_name=(user.name or user.username),
exclude_credentials=exclude,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.PREFERRED,
resident_key=ResidentKeyRequirement.PREFERRED,
),
)
challenge_b64 = _b64url_encode(options.challenge)
return json.loads(options_to_json(options)), challenge_b64
def finish_registration(user, response_json: dict, expected_challenge_b64: str,
label: str = '') -> Dict:
"""Verify the authenticator's registration response and persist the cred.
Raises webauthn.exceptions.InvalidRegistrationResponse on failure.
Returns the credential dict that was added to user.webauthn_credentials.
"""
from src.database import db
expected_challenge = _b64url_decode(expected_challenge_b64)
verification = verify_registration_response(
credential=response_json,
expected_challenge=expected_challenge,
expected_origin=get_expected_origin(),
expected_rp_id=get_rp_id(),
require_user_verification=False,
)
new_cred = {
'id': _b64url_encode(verification.credential_id),
'public_key': _b64url_encode(verification.credential_public_key),
'sign_count': verification.sign_count,
'transports': response_json.get('response', {}).get('transports', []),
'name': (label or 'Passkey').strip()[:80],
'created_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
}
creds = list(user.webauthn_credentials or [])
creds.append(new_cred)
user.webauthn_credentials = creds
db.session.commit()
return new_cred
# --- Authentication ------------------------------------------------------------
def begin_authentication(user) -> Tuple[dict, str]:
"""Generate authentication options scoped to this user's credentials.
Returns (json-safe options dict, challenge_b64url) — store the challenge
in session keyed by pending_totp_user_id during /2fa/verify.
Raises ValueError if the user has no registered credentials.
"""
existing = list_user_credentials(user)
if not existing:
raise ValueError('User has no registered passkeys')
allow = [
PublicKeyCredentialDescriptor(id=_b64url_decode(c['id']))
for c in existing
]
options = generate_authentication_options(
rp_id=get_rp_id(),
allow_credentials=allow,
user_verification=UserVerificationRequirement.PREFERRED,
)
challenge_b64 = _b64url_encode(options.challenge)
return json.loads(options_to_json(options)), challenge_b64
def finish_authentication(user, response_json: dict,
expected_challenge_b64: str) -> Dict:
"""Verify the authenticator assertion, increment sign_count, return cred.
Anti-cloning per WebAuthn §6.1.1: every successful auth must update
the stored sign_count from the new monotonic counter. python-webauthn
raises if the new counter is not strictly greater than the stored one
(when both are non-zero).
"""
from src.database import db
expected_challenge = _b64url_decode(expected_challenge_b64)
cred_id_b64 = response_json.get('id') or response_json.get('rawId')
if not cred_id_b64:
raise ValueError('Missing credential id in authentication response')
# Take a fresh list with shallow-copied dicts so SQLAlchemy's JSON-column
# change detection sees a new value (mutating the existing list in place
# would not flag the row dirty under the default JSON type).
creds = [dict(c) for c in list_user_credentials(user)]
matched_idx = None
for i, c in enumerate(creds):
if c['id'] == cred_id_b64:
matched_idx = i
break
if matched_idx is None:
raise ValueError('Credential not registered for this user')
matched = creds[matched_idx]
verification = verify_authentication_response(
credential=response_json,
expected_challenge=expected_challenge,
expected_origin=get_expected_origin(),
expected_rp_id=get_rp_id(),
credential_public_key=_b64url_decode(matched['public_key']),
credential_current_sign_count=matched['sign_count'],
require_user_verification=False,
)
matched['sign_count'] = verification.new_sign_count
creds[matched_idx] = matched
user.webauthn_credentials = creds
db.session.commit()
return matched
# --- Deletion ------------------------------------------------------------------
def delete_credential(user, credential_id_b64: str) -> bool:
"""Remove a credential by its base64url id.
Returns True if removed, False if not found. Sets the JSON column to
None when the last credential is removed (matches the column's
nullable=True semantics).
"""
from src.database import db
creds = list_user_credentials(user)
new_creds = [c for c in creds if c['id'] != credential_id_b64]
if len(new_creds) == len(creds):
return False
user.webauthn_credentials = new_creds if new_creds else None
db.session.commit()
return True

36
src/billing/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
"""Billing blueprint - Stripe Checkout, webhook, subscription management.
Mounted at /checkout/* prefix for the customer-facing checkout flow.
The webhook (B-2.8) is exposed at /checkout/webhooks/stripe and is
CSRF-exempted via `exempt_webhook_csrf` (signature-verified instead).
Routes added in Tasks B-2.7 (checkout) and B-2.8 (webhook).
"""
from flask import Blueprint
# template_folder points at the project-level `templates/` so render_template
# can resolve names like 'billing/success.html' the same way the marketing
# and legal blueprints resolve 'marketing/...' / 'legal/...'.
billing_bp = Blueprint(
'billing',
__name__,
url_prefix='/checkout',
template_folder='../../templates',
static_folder=None,
)
# Import routes to register them on billing_bp. Must come after blueprint
# instantiation. Keep the # noqa comments — these guards exist for ruff/flake8.
from src.billing import routes # noqa: E402, F401
from src.billing import webhooks # noqa: E402, F401
def exempt_webhook_csrf(csrf_protect):
"""Exempt the Stripe webhook view from CSRF protection.
Called from app.py after CSRFProtect is initialized. Stripe webhooks have
no CSRF token (server-to-server). The `stripe_webhook` view validates
Stripe's signature header (`Stripe-Signature` + STRIPE_WEBHOOK_SECRET) instead.
"""
from src.billing.webhooks import stripe_webhook
csrf_protect.exempt(stripe_webhook)

163
src/billing/plans.py Normal file
View File

@@ -0,0 +1,163 @@
"""DictIA pricing plans — v7.0 (B-2.7 refonte 2026-04-27).
Centralized plan registry. Stripe Price IDs are resolved from environment
variables — set STRIPE_<PLAN>_<PERIOD> env vars in production. The slug
(`cloud-basic`, `cloud-essentiel`, `cloud-pro`, `dictia-local`) is the
canonical identifier used throughout the codebase (URL params, webhook
metadata, audit logs).
v7.0 pricing reference (CAD, pre-tax — TPS/TVQ added by Stripe automatic_tax):
- Cloud BASIC : 189 $/mo recurring (no setup)
yearly = 189 × 12 × 0.85 ≈ 1 928 $/an
- Cloud ESSENTIEL : 349 $/mo recurring (no setup)
yearly = 349 × 12 × 0.85 ≈ 3 559 $/an
- Cloud PRO : 549 $/mo recurring + 485 $ one-time onboarding setup
yearly = 549 × 12 × 0.85 ≈ 5 600 $/an (+ 485 $ setup)
- DictIA LOCAL : 5 998 $ one-time (An 1 = matériel + 1ʳᵉ année logiciel)
puis 500 $/an dès An 2 (renewal yearly only — no monthly)
Pro+ is a sentinel plan — no Stripe Price IDs, the route redirects to
/contact?pro-plus=1 instead of opening Stripe Checkout. It exists in PLANS
so other code (URL routing, navigation) can identify it; `is_configured()`
always returns False so the route falls through to the contact redirect.
"""
import os
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass(frozen=True)
class Plan:
"""A DictIA subscription plan (v7.0).
Stripe Price IDs are resolved lazily from environment variables — the
Plan instance itself only stores the variable names. This lets the
application boot without Stripe credentials (CI, dev branches) and
keeps secrets out of source control.
Three pricing shapes are supported:
- Cloud Basic / Essentiel : monthly + yearly (no setup, no renewal)
- Cloud Pro : monthly + yearly + setup (one-time onboarding)
- DictIA Local : setup (An 1) + yearly_renewal (dès An 2)
— no monthly Price ID
The Pro+ plan has all *_env fields set to None — the route checks
`is_quote_only` and redirects to /contact instead of opening Checkout.
"""
slug: str
name: str
description_fr: str
has_setup_fee: bool
monthly_env: Optional[str] = None
yearly_env: Optional[str] = None
setup_env: Optional[str] = None # Cloud Pro setup OR DictIA Local An 1
yearly_renewal_env: Optional[str] = None # DictIA Local An 2+ renewal
is_quote_only: bool = False # True for Pro+ (no Stripe — redirect to contact)
def setup_price_id(self) -> Optional[str]:
if not self.has_setup_fee or not self.setup_env:
return None
return os.environ.get(self.setup_env)
def monthly_price_id(self) -> Optional[str]:
if not self.monthly_env:
return None
return os.environ.get(self.monthly_env)
def yearly_price_id(self) -> Optional[str]:
if not self.yearly_env:
return None
return os.environ.get(self.yearly_env)
def yearly_renewal_price_id(self) -> Optional[str]:
if not self.yearly_renewal_env:
return None
return os.environ.get(self.yearly_renewal_env)
def is_configured(self) -> bool:
"""True when all required Stripe Price IDs are set in the environment.
- Quote-only plans (Pro+) are never configured (always redirect to /contact).
- Cloud plans require monthly + yearly Price IDs.
- Cloud Pro additionally requires the one-time setup Price ID.
- DictIA Local requires setup (An 1) + yearly_renewal (dès An 2).
"""
if self.is_quote_only:
return False
# DictIA Local (one-shot + yearly renewal — no monthly)
if self.setup_env and self.yearly_renewal_env and not self.monthly_env:
return bool(self.setup_price_id() and self.yearly_renewal_price_id())
# Cloud plans (monthly + yearly required, + setup if Pro)
if self.has_setup_fee and not self.setup_price_id():
return False
return bool(self.monthly_price_id() and self.yearly_price_id())
def price_id_for_period(self, period: str) -> Optional[str]:
"""Resolve the Price ID for the given billing period.
For DictIA Local (no monthly), 'monthly' falls back to the yearly_renewal
Price ID — Stripe Checkout will display the recurrence in the session UI.
"""
if not self.monthly_env and self.yearly_renewal_env:
# DictIA Local — only a yearly renewal exists
return self.yearly_renewal_price_id()
return self.yearly_price_id() if period == 'yearly' else self.monthly_price_id()
PLANS: Dict[str, Plan] = {
'cloud-basic': Plan(
slug='cloud-basic',
name='Cloud BASIC',
description_fr='Cloud souverain QC — 189 $/mo · solopreneur, petite équipe.',
has_setup_fee=False,
monthly_env='STRIPE_CLOUD_BASIC_MONTHLY',
yearly_env='STRIPE_CLOUD_BASIC_YEARLY',
),
'cloud-essentiel': Plan(
slug='cloud-essentiel',
name='Cloud ESSENTIEL',
description_fr='Cloud souverain QC — 349 $/mo · cabinet en croissance.',
has_setup_fee=False,
monthly_env='STRIPE_CLOUD_ESSENTIEL_MONTHLY',
yearly_env='STRIPE_CLOUD_ESSENTIEL_YEARLY',
),
'cloud-pro': Plan(
slug='cloud-pro',
name='Cloud PRO',
description_fr='Cloud souverain QC — 549 $/mo + 485 $ onboarding · usage intensif multi-postes.',
has_setup_fee=True,
setup_env='STRIPE_CLOUD_PRO_SETUP',
monthly_env='STRIPE_CLOUD_PRO_MONTHLY',
yearly_env='STRIPE_CLOUD_PRO_YEARLY',
),
'dictia-local': Plan(
slug='dictia-local',
name='DictIA LOCAL',
description_fr='100 % hors-ligne — 5 998 $ An 1 (matériel + logiciel) puis 500 $/an dès An 2.',
has_setup_fee=True,
setup_env='STRIPE_DICTIA_LOCAL_SETUP',
yearly_renewal_env='STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY',
# No monthly_env / yearly_env — local plan is one-shot + yearly renewal
),
'pro-plus': Plan(
slug='pro-plus',
name='Pro+',
description_fr='Soumission personnalisée — > 660 h audio/mois, multi-sites, SLA 99,9 %, SOC 2.',
has_setup_fee=False,
is_quote_only=True,
),
}
VALID_PERIODS = ('monthly', 'yearly')
def get_plan(slug: str) -> Optional[Plan]:
"""Return the Plan for `slug`, or None if unknown."""
if not slug:
return None
return PLANS.get(slug)
def list_plans() -> List[Plan]:
"""Return all registered plans in registration order."""
return list(PLANS.values())

130
src/billing/routes.py Normal file
View File

@@ -0,0 +1,130 @@
"""Billing routes — Stripe Checkout (B-2.7).
URL space (prefix `/checkout`, set on billing_bp):
- GET /checkout/<plan>?period=monthly|yearly → 303 redirect to Stripe-hosted Checkout
- GET /checkout/success?session_id=... → confirmation page (async activation note)
- GET /checkout/cancel → friendly "no charge made" page
The webhook route (B-2.8) is registered at /checkout/webhooks/stripe (under
the same blueprint prefix) and is CSRF-exempt (signature-verified instead).
"""
import logging
from flask import (
Blueprint, current_app, flash, redirect, render_template,
request, url_for,
)
from flask_login import current_user, login_required
from src.billing import billing_bp
from src.billing.plans import VALID_PERIODS, get_plan
from src.billing.stripe_client import (
StripeNotConfiguredError,
create_checkout_session,
is_stripe_configured,
)
logger = logging.getLogger(__name__)
@billing_bp.route('/<plan>')
@login_required
def checkout(plan):
"""Initiate Stripe Checkout for the given plan + period.
Redirects to /tarifs with a French flash on any error (unknown plan,
Stripe not configured, plan Price IDs missing, Stripe API failure).
Returns a 303 See Other redirect to the Stripe-hosted Checkout on success
(303 is what Stripe documents for HTTP redirects to checkout.stripe.com).
"""
plan_obj = get_plan(plan)
if plan_obj is None:
flash('Forfait inconnu.', 'danger')
return redirect(url_for('marketing.tarifs'))
# Pro+ — soumission personnalisée (no Stripe Checkout, redirect to /contact)
if plan_obj.is_quote_only:
return redirect(url_for('marketing.contact') + '?pro-plus=1')
period = request.args.get('period', 'monthly')
if period not in VALID_PERIODS:
period = 'monthly'
if not is_stripe_configured():
flash(
"Le paiement en ligne n'est pas disponible pour le moment. "
"Contactez info@dictia.ca pour finaliser votre abonnement.",
'warning',
)
return redirect(url_for('marketing.tarifs'))
if not plan_obj.is_configured():
flash(
"Ce forfait n'est pas encore configuré. Contactez info@dictia.ca.",
'warning',
)
return redirect(url_for('marketing.tarifs'))
success_url = url_for('billing.success', _external=True)
cancel_url = url_for('billing.cancel', _external=True)
try:
session = create_checkout_session(
plan_slug=plan,
period=period,
user=current_user,
success_url=success_url,
cancel_url=cancel_url,
)
except StripeNotConfiguredError as e:
logger.error('Stripe not configured at checkout: %s', e)
flash(
"Le paiement en ligne n'est pas disponible. "
"Contactez info@dictia.ca.",
'warning',
)
return redirect(url_for('marketing.tarifs'))
except ValueError as e:
logger.warning('Invalid checkout request: %s', e)
flash('Demande de paiement invalide.', 'danger')
return redirect(url_for('marketing.tarifs'))
except Exception as e: # noqa: BLE001
logger.exception(
'Stripe Checkout creation failed for user %s plan %s: %s',
getattr(current_user, 'id', '?'), plan, e,
)
flash(
"Une erreur est survenue lors de l'ouverture du paiement. "
"Réessayez ou contactez info@dictia.ca.",
'danger',
)
return redirect(url_for('marketing.tarifs'))
# Stripe documents 303 See Other for hosted-Checkout redirects.
return redirect(session.url, code=303)
@billing_bp.route('/success')
def success():
"""Post-payment confirmation page.
The session_id query param is preserved for optional client-side analytics
but is NOT trusted server-side — Stripe's webhook (B-2.8) is the source of
truth for subscription state. This page makes that asynchrony explicit
("Votre abonnement sera activé sous quelques minutes.").
"""
session_id = request.args.get('session_id')
return render_template(
'billing/success.html',
title='Paiement confirmé — DictIA',
session_id=session_id,
)
@billing_bp.route('/cancel')
def cancel():
"""User cancelled the Stripe Checkout. No state to revert; no charge made."""
return render_template(
'billing/cancel.html',
title='Paiement annulé — DictIA',
)

View File

@@ -0,0 +1,139 @@
"""Stripe SDK client wrapper (B-2.7).
Lazy-initializes stripe.api_key from STRIPE_SECRET_KEY at first use, so the
app can boot without Stripe credentials (CI, dev, contributor branches).
Raises StripeNotConfiguredError if a Stripe API call is attempted without
the key set.
This module is intentionally thin: it owns the stripe.* call surface used by
B-2.7 (Checkout) and is reused by B-2.8 (webhook signature verification).
No subscription state is persisted here — the webhook is the source of truth
for `user.subscription_status`. The only User mutation is `stripe_customer_id`
(identity, not state).
"""
import os
from typing import List
import stripe
class StripeNotConfiguredError(RuntimeError):
"""Raised when STRIPE_SECRET_KEY (or a plan Price ID) is missing at call time."""
def is_stripe_configured() -> bool:
"""Return True if STRIPE_SECRET_KEY is set in the environment."""
return bool(os.environ.get('STRIPE_SECRET_KEY'))
def _ensure_configured() -> None:
"""Lazy-initialize stripe.api_key. Raises if STRIPE_SECRET_KEY is missing."""
if not is_stripe_configured():
raise StripeNotConfiguredError(
'STRIPE_SECRET_KEY is not set. Configure it before using billing.'
)
if not stripe.api_key:
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
def get_or_create_customer(user) -> str:
"""Return the Stripe customer ID for `user`, creating one if needed.
Persists the Stripe customer ID on user.stripe_customer_id so subsequent
checkouts (and the webhook) can correlate Stripe events back to the user.
"""
from src.database import db
_ensure_configured()
if user.stripe_customer_id:
return user.stripe_customer_id
customer = stripe.Customer.create(
email=user.email,
name=(user.name or user.username),
metadata={
'dictia_user_id': str(user.id),
'dictia_username': user.username,
},
)
user.stripe_customer_id = customer.id
db.session.commit()
return customer.id
def create_checkout_session(
plan_slug: str,
period: str,
user,
success_url: str,
cancel_url: str,
):
"""Create a Stripe Checkout Session for the given plan + period.
Configuration applied:
- mode='subscription' (recurring)
- currency='cad'
- automatic_tax.enabled=true (Stripe applies TPS 5% + TVQ 9.975%)
- billing_address_collection='required' (needed for Tax)
- allow_promotion_codes=true
- Apple/Google Pay are auto-enabled for card payments in Stripe Dashboard
- Hardware plans (8/16) include a one-time setup line item AND the
recurring subscription line item.
The success_url is decorated with `?session_id={CHECKOUT_SESSION_ID}` so
the success page can optionally surface the session id (analytics).
"""
from src.billing.plans import VALID_PERIODS, get_plan
_ensure_configured()
plan = get_plan(plan_slug)
if plan is None:
raise ValueError(f'Unknown plan: {plan_slug!r}')
if period not in VALID_PERIODS:
raise ValueError(
f'Invalid period: {period!r} (expected one of {VALID_PERIODS})'
)
if not plan.is_configured():
raise StripeNotConfiguredError(
f'Stripe Price IDs for {plan_slug!r} are not set in environment.'
)
customer_id = get_or_create_customer(user)
line_items: List[dict] = []
# One-time setup fee for hardware plans (DictIA 8 / DictIA 16)
if plan.has_setup_fee:
setup_id = plan.setup_price_id()
if setup_id:
line_items.append({'price': setup_id, 'quantity': 1})
# Recurring subscription
line_items.append({
'price': plan.price_id_for_period(period),
'quantity': 1,
})
# Inject CHECKOUT_SESSION_ID placeholder while preserving any existing query string
decorated_success_url = success_url + (
'&' if '?' in success_url else '?'
) + 'session_id={CHECKOUT_SESSION_ID}'
metadata = {
'dictia_user_id': str(user.id),
'dictia_plan_slug': plan_slug,
'dictia_period': period,
}
return stripe.checkout.Session.create(
mode='subscription',
customer=customer_id,
line_items=line_items,
success_url=decorated_success_url,
cancel_url=cancel_url,
automatic_tax={'enabled': True},
currency='cad',
billing_address_collection='required',
customer_update={'address': 'auto', 'name': 'auto'},
allow_promotion_codes=True,
metadata=metadata,
# Webhook (B-2.8) reads metadata off the subscription, not the session
subscription_data={'metadata': metadata},
)

320
src/billing/webhooks.py Normal file
View File

@@ -0,0 +1,320 @@
"""Stripe webhook handler (B-2.8) — subscription lifecycle.
Endpoint: POST /checkout/webhooks/stripe (CSRF-exempt; signature verified)
Handled events:
- checkout.session.completed: create Subscription row, set User.subscription_status
- customer.subscription.updated: update status + current_period_end
- customer.subscription.deleted: mark status='canceled', clear User.subscription_status
- invoice.payment_succeeded: touch updated_at (renewal confirmation)
- invoice.payment_failed: set status='past_due'
All other event types are acknowledged with 200 but ignored.
Idempotency: every processed event ID is recorded in WebhookEvent.
Duplicate deliveries return 200 immediately without re-processing.
"""
import logging
import os
from datetime import datetime, timezone
from typing import Optional
import stripe
from flask import jsonify, request
from src.billing import billing_bp
from src.billing.plans import VALID_PERIODS, get_plan
from src.billing.stripe_client import is_stripe_configured
from src.database import db
from src.models import Subscription, User, WebhookEvent
logger = logging.getLogger(__name__)
def get_webhook_secret() -> Optional[str]:
"""Return STRIPE_WEBHOOK_SECRET, or None if not configured."""
return os.environ.get('STRIPE_WEBHOOK_SECRET')
def is_webhook_configured() -> bool:
return bool(get_webhook_secret() and is_stripe_configured())
def _verify_event(payload: bytes, sig_header: str):
"""Validate Stripe signature and return the parsed event, or None on failure."""
secret = get_webhook_secret()
if not secret:
logger.error('STRIPE_WEBHOOK_SECRET not set; rejecting webhook')
return None
try:
return stripe.Webhook.construct_event(payload, sig_header, secret)
except ValueError:
logger.warning('Stripe webhook: invalid JSON payload')
return None
except stripe.error.SignatureVerificationError:
logger.warning('Stripe webhook: signature verification failed')
return None
def _is_duplicate(event_id: str) -> bool:
return WebhookEvent.query.filter_by(stripe_event_id=event_id).first() is not None
def _resolve_user_for_event(event_obj: dict) -> Optional[User]:
"""Resolve the DictIA User from a Stripe event object.
Trust order (anti-tamper per B-2.7 review note):
1. Look up by stripe_customer_id on the event object — this is server-set
by Stripe at customer creation, not user-controlled.
2. Fall back to event metadata 'dictia_user_id', re-validated against DB.
3. Fall back to customer_email lookup (last resort, rare for subscriptions).
"""
cust_id = event_obj.get('customer')
if cust_id:
user = User.query.filter_by(stripe_customer_id=cust_id).first()
if user:
return user
metadata = event_obj.get('metadata') or {}
raw_user_id = metadata.get('dictia_user_id')
if raw_user_id:
try:
uid = int(raw_user_id)
except (TypeError, ValueError):
uid = None
if uid is not None:
user = db.session.get(User, uid)
if user:
# Bind stripe_customer_id if missing (defensive)
if not user.stripe_customer_id and cust_id:
user.stripe_customer_id = cust_id
return user
email = event_obj.get('customer_email')
if email:
user = User.query.filter_by(email=email.lower().strip()).first()
if user and cust_id and not user.stripe_customer_id:
user.stripe_customer_id = cust_id
return user
return None
def _resolve_plan_period(event_obj: dict, default_period: str = 'monthly') -> tuple:
"""Extract plan_slug and period from event metadata, validating both."""
metadata = event_obj.get('metadata') or {}
plan_slug = metadata.get('dictia_plan_slug')
period = metadata.get('dictia_period', default_period)
if get_plan(plan_slug) is None:
plan_slug = None # invalid / missing — leave for handler to log
if period not in VALID_PERIODS:
period = default_period
return plan_slug, period
def _ts_to_dt(ts) -> Optional[datetime]:
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc).replace(tzinfo=None)
except (TypeError, ValueError, OSError):
return None
def _record_event(event, sub_id: Optional[str], cust_id: Optional[str]) -> None:
"""Insert a WebhookEvent row marking this event as processed."""
db.session.add(WebhookEvent(
stripe_event_id=event.id,
event_type=event.type,
stripe_subscription_id=sub_id,
stripe_customer_id=cust_id,
))
def _handle_checkout_session_completed(event) -> None:
obj = event.data.object # stripe.checkout.Session
user = _resolve_user_for_event(obj)
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
plan_slug, period = _resolve_plan_period(obj)
if not user:
logger.warning('checkout.session.completed: no user for cust=%s sub=%s', cust_id, sub_id)
_record_event(event, sub_id, cust_id)
return
if not sub_id:
logger.warning('checkout.session.completed: missing subscription id for user %s', user.id)
_record_event(event, sub_id, cust_id)
return
if not plan_slug:
logger.warning('checkout.session.completed: missing/invalid plan_slug metadata for sub=%s', sub_id)
plan_slug = 'unknown'
# Look up the existing subscription row (defensive against duplicate webhooks)
existing = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
now = datetime.utcnow()
if existing:
existing.status = 'active'
existing.updated_at = now
else:
# We need current_period_end — pull it from the subscription object
# if the event includes it; otherwise leave None and let
# customer.subscription.updated fill it in.
period_end = None
# Fetch the subscription via Stripe API for accurate period_end
try:
from src.billing.stripe_client import _ensure_configured
_ensure_configured()
sub_obj = stripe.Subscription.retrieve(sub_id)
period_end = _ts_to_dt(sub_obj.get('current_period_end'))
except Exception as e:
logger.warning('Could not fetch subscription %s for period_end: %s', sub_id, e)
db.session.add(Subscription(
user_id=user.id,
stripe_customer_id=cust_id,
stripe_subscription_id=sub_id,
plan_slug=plan_slug,
period=period,
status='active',
current_period_end=period_end,
created_at=now,
updated_at=now,
))
user.subscription_status = 'active'
if cust_id and not user.stripe_customer_id:
user.stripe_customer_id = cust_id
_record_event(event, sub_id, cust_id)
def _handle_subscription_updated(event) -> None:
obj = event.data.object # stripe.Subscription
sub_id = obj.get('id')
cust_id = obj.get('customer')
new_status = obj.get('status')
period_end = _ts_to_dt(obj.get('current_period_end'))
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': obj.get('metadata') or {}})
now = datetime.utcnow()
if sub:
if new_status:
sub.status = new_status
if period_end:
sub.current_period_end = period_end
sub.updated_at = now
else:
# Webhook arrived before we created the row (race) — create defensively
plan_slug, period = _resolve_plan_period(obj)
db.session.add(Subscription(
user_id=user.id if user else None,
stripe_customer_id=cust_id,
stripe_subscription_id=sub_id,
plan_slug=plan_slug or 'unknown',
period=period,
status=new_status or 'unknown',
current_period_end=period_end,
created_at=now,
updated_at=now,
))
if user and new_status:
user.subscription_status = new_status
_record_event(event, sub_id, cust_id)
def _handle_subscription_deleted(event) -> None:
obj = event.data.object
sub_id = obj.get('id')
cust_id = obj.get('customer')
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': obj.get('metadata') or {}})
now = datetime.utcnow()
if sub:
sub.status = 'canceled'
sub.updated_at = now
if user:
user.subscription_status = 'canceled'
_record_event(event, sub_id, cust_id)
def _handle_invoice_payment_succeeded(event) -> None:
obj = event.data.object # stripe.Invoice
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
if sub_id:
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
if sub:
sub.updated_at = datetime.utcnow()
if sub.status == 'past_due':
sub.status = 'active'
user = _resolve_user_for_event({'customer': cust_id, 'metadata': {}})
if user:
user.subscription_status = 'active'
_record_event(event, sub_id, cust_id)
def _handle_invoice_payment_failed(event) -> None:
obj = event.data.object
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
if sub_id:
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': {}})
if sub:
sub.status = 'past_due'
sub.updated_at = datetime.utcnow()
if user:
user.subscription_status = 'past_due'
_record_event(event, sub_id, cust_id)
_HANDLERS = {
'checkout.session.completed': _handle_checkout_session_completed,
'customer.subscription.updated': _handle_subscription_updated,
'customer.subscription.deleted': _handle_subscription_deleted,
'invoice.payment_succeeded': _handle_invoice_payment_succeeded,
'invoice.payment_failed': _handle_invoice_payment_failed,
}
@billing_bp.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
"""Stripe webhook endpoint. Signature-verified; CSRF-exempt.
Returns 400 on signature failure (Stripe will retry); 200 otherwise
(even for unhandled event types, to acknowledge receipt).
"""
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature', '')
event = _verify_event(payload, sig_header)
if event is None:
return jsonify({'error': 'invalid_signature'}), 400
# Idempotency check
if _is_duplicate(event.id):
logger.info('Stripe webhook: duplicate event %s ignored', event.id)
return jsonify({'received': True, 'duplicate': True})
handler = _HANDLERS.get(event.type)
if handler is None:
# Unhandled event type — record + ack so Stripe stops retrying
_record_event(event, None, None)
try:
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({'received': True, 'handled': False})
try:
handler(event)
db.session.commit()
except Exception as e:
logger.exception('Stripe webhook: handler for %s failed: %s', event.type, e)
db.session.rollback()
# Return 500 so Stripe retries — but only for genuine handler failures,
# not for malformed/unhandled events
return jsonify({'error': 'handler_failed'}), 500
return jsonify({'received': True})

View File

@@ -284,6 +284,34 @@ def initialize_database(app):
app.logger.info("Added transcription_hotwords column to user table") app.logger.info("Added transcription_hotwords column to user table")
if add_column_if_not_exists(engine, 'user', 'transcription_initial_prompt', 'TEXT'): if add_column_if_not_exists(engine, 'user', 'transcription_initial_prompt', 'TEXT'):
app.logger.info("Added transcription_initial_prompt column to user table") app.logger.info("Added transcription_initial_prompt column to user table")
# === B-2.1: MFA / WebAuthn / Stripe / Loi 25 user fields ===
if add_column_if_not_exists(engine, 'user', 'totp_secret_encrypted', 'VARCHAR(255)'):
app.logger.info("Added 'totp_secret_encrypted' column to user")
if add_column_if_not_exists(engine, 'user', 'totp_enabled', 'BOOLEAN DEFAULT 0'):
app.logger.info("Added 'totp_enabled' column to user")
if add_column_if_not_exists(engine, 'user', 'webauthn_credentials', 'JSON'):
app.logger.info("Added webauthn_credentials column to user table")
# B-2.5: 10 single-use bcrypt-hashed recovery codes for TOTP MFA
if add_column_if_not_exists(engine, 'user', 'totp_recovery_codes', 'JSON'):
app.logger.info("Added totp_recovery_codes column to user table")
if add_column_if_not_exists(engine, 'user', 'ordre_pro', 'VARCHAR(50)'):
app.logger.info("Added ordre_pro column to user table")
if add_column_if_not_exists(engine, 'user', 'cabinet', 'VARCHAR(255)'):
app.logger.info("Added cabinet column to user table")
if add_column_if_not_exists(engine, 'user', 'stripe_customer_id', 'VARCHAR(120)'):
app.logger.info("Added stripe_customer_id column to user table")
if add_column_if_not_exists(engine, 'user', 'subscription_status', 'VARCHAR(20)'):
app.logger.info("Added subscription_status column to user table")
# === B-2.1: Indexes on stripe_customer_id and subscription_status ===
try:
if create_index_if_not_exists(engine, 'idx_user_stripe_customer', 'user', 'stripe_customer_id'):
app.logger.info("Created index idx_user_stripe_customer on user.stripe_customer_id")
if create_index_if_not_exists(engine, 'idx_user_subscription_status', 'user', 'subscription_status'):
app.logger.info("Created index idx_user_subscription_status on user.subscription_status")
except Exception as e:
app.logger.warning(f"Could not create B-2.1 user indexes: {e}")
if add_column_if_not_exists(engine, 'tag', 'default_hotwords', 'TEXT'): if add_column_if_not_exists(engine, 'tag', 'default_hotwords', 'TEXT'):
app.logger.info("Added default_hotwords column to tag table") app.logger.info("Added default_hotwords column to tag table")
if add_column_if_not_exists(engine, 'tag', 'default_initial_prompt', 'TEXT'): if add_column_if_not_exists(engine, 'tag', 'default_initial_prompt', 'TEXT'):

24
src/legal/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""Legal blueprint - Conditions, Confidentialite (Loi 25), Cookies, Remboursement,
Accessibilite, Mentions.
Mounted at /legal/* prefix. Content rendered from markdown files in
src/legal/content/ (B-2.9). All 6 pages publicly indexable (Loi 25 transparency).
"""
from flask import Blueprint
# Canonical version of all 6 legal documents. Bump when ANY of the markdown
# files in src/legal/content/ is updated. Stored on every ConsentLog row at
# signup time (src/api/auth.py uses this to stamp consent_log.version).
# Format: ISO date 'YYYY-MM-DD' of the document revision.
LEGAL_VERSION = '2026-04-27'
legal_bp = Blueprint(
'legal',
__name__,
url_prefix='/legal',
template_folder='../../templates/legal',
static_folder=None,
)
# Register routes
from src.legal import routes # noqa: E402, F401

View File

@@ -0,0 +1,66 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Ce document a valeur informative jusqu'à la revue finale par la responsable légale de DictIA Inc.
</div>
## 1. Engagement DictIA pour l'accessibilité numérique
DictIA Inc. (NEQ 1181949562, 77 chemin de la Seigneurie, Inverness QC G0S 1K0) considère que l'accessibilité numérique est un droit fondamental. Notre mission de transcription IA conforme à la Loi 25 s'adresse à des professionnels exigeants, dont certains vivent avec un handicap visuel, auditif, moteur ou cognitif. Nous nous engageons à rendre nos interfaces utilisables par toutes et tous.
## 2. Niveau de conformité visé
Le niveau de conformité visé par DictIA est **WCAG 2.2 niveau AA** (*Web Content Accessibility Guidelines*, version 2.2, niveau AA — recommandation officielle du W3C).
Ce standard couvre les quatre principes fondamentaux : perceptible, utilisable, compréhensible et robuste.
## 3. Standards techniques appliqués
L'équipe DictIA applique systématiquement les bonnes pratiques suivantes lors du développement :
- **Sémantique HTML5** : utilisation appropriée des balises `<header>`, `<nav>`, `<main>`, `<article>`, `<section>`, `<footer>` et de la hiérarchie des titres `<h1>` à `<h6>`.
- **Contraste des couleurs** : ratio minimal de 4,5:1 pour le texte normal et 3:1 pour le texte large, vérifié avec Lighthouse et WAVE.
- **Focus visible** : chaque élément interactif possède un indicateur de focus distinct (`focus-visible:outline`) compatible avec la navigation clavier.
- **Navigation clavier** : toutes les fonctionnalités sont accessibles via le clavier (Tab, Shift+Tab, Entrée, Espace, Échap).
- **Attributs ARIA** : utilisation parcimonieuse et conforme à la spécification (`aria-label`, `aria-labelledby`, `aria-describedby`, `role`, `aria-current`).
- **Préférences de mouvement** : respect strict de `prefers-reduced-motion: reduce` (animations désactivées si l'utilisateur a configuré sa préférence).
- **Texte alternatif** : chaque image porteuse de sens dispose d'un attribut `alt` descriptif ; les images décoratives portent `alt=""`.
- **Formulaires accessibles** : chaque champ est associé à un `<label>` explicite, les erreurs sont annoncées via `aria-live="polite"`.
- **Langue déclarée** : `<html lang="fr-CA">` sur toutes les pages.
## 4. Ce qui est conforme
À la date de publication de la présente déclaration, les sections suivantes du Service ont été auditées et sont jugées conformes au niveau WCAG 2.2 AA :
- **Pages marketing** : <https://dictia.ca/>, /fonctionnalites, /tarifs, /conformite, /blog, /contact ;
- **Pages d'authentification** : /login, /signup, /forgot-password, flux MFA ;
- **Pages légales** : /legal/* (les 6 documents légaux dont vous lisez actuellement l'un des éléments) ;
- **Pages de facturation** : /billing/checkout, /billing/success, /billing/portal.
## 5. Ce qui n'est pas encore pleinement conforme
Nous reconnaissons honnêtement les limitations actuelles :
- **Tableau de bord application** (interface de gestion des transcriptions) : audit en cours, finalisation prévue à la phase B-3.x ;
- **Lecteur audio synchronisé** : les contrôles clavier sont fonctionnels, mais l'expérience pour les utilisateurs de lecteurs d'écran fait l'objet d'améliorations continues ;
- **Templates de courriels transactionnels** : la conformité dépend partiellement des limitations propres à chaque client de messagerie (Outlook, Gmail, Apple Mail).
Ces zones sont publiquement signalées par souci de transparence — ce n'est pas parce que c'est imparfait que ce n'est pas honnête.
## 6. Comment signaler un problème d'accessibilité
Si vous rencontrez un obstacle d'accessibilité sur l'un des sites ou services DictIA, écrivez-nous à :
- **Courriel** : <info@dictia.ca> avec pour sujet « **Accessibilité** »
- **Adresse postale** : DictIA Inc. — Accessibilité, 77 chemin de la Seigneurie, Inverness QC G0S 1K0
Précisez la page concernée (URL), votre navigateur et votre système d'exploitation, et la description du problème rencontré (technologie d'assistance utilisée si pertinent). Nous nous engageons à accuser réception sous 2 jours ouvrables et à vous proposer une solution sous 30 jours.
## 7. Voies de recours
Si la réponse de DictIA Inc. ne vous satisfait pas, vous pouvez saisir la **Commission des droits de la personne et des droits de la jeunesse du Québec** :
- **Site web** : <https://www.cdpdj.qc.ca>
- **Téléphone** : 1 800 361-6477
## 8. Date de mise à jour
Version 2026-04-27 — Inverness, Québec.

View File

@@ -0,0 +1,187 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Ce document a valeur informative jusqu'à la revue finale par la responsable légale de DictIA Inc. La version contractuelle de référence est le document signé <code>CGU-CLIENT_DictIA</code> (v1.0, 9 mars 2026).
</div>
## 1. Identification des parties
Les présentes Conditions générales d'utilisation (ci-après les « **Conditions** » ou « **CGU** ») régissent l'accès et l'utilisation des services offerts par :
- **Raison sociale** : DictIA Inc. — NEQ 1181949562
- **Forme juridique** : société par actions constituée le 22 mars 2026 en vertu de la *Loi sur les sociétés par actions du Québec* (RLRQ, c. S-31.1)
- **Siège social** : 77 chemin de la Seigneurie, Inverness QC G0S 1K0, Canada
- **District judiciaire** : Québec
- **Actionnaires** : Allison Rioux (50&nbsp;%) et Jean-David Lévesque-Rioux (50&nbsp;%)
- **Téléphone** : 581 996-8471
- **Courriel** : <info@dictia.ca>
- **Site web** : <https://dictia.ca>
(ci-après le « **Fournisseur** » ou « **DictIA Inc.** »)
ET le **Client** ou l'**Utilisateur** ayant accepté les présentes CGU.
Les présentes CGU sont rédigées en langue française conformément à la *Charte de la langue française* (RLRQ, c. C-11.1) telle que modifiée par la Loi 96. **En cas de traduction, la version française prévaut en tout temps.**
## 2. Description du service DictIA
### 2.1 Nature du service
DictIA est un logiciel de transcription audio automatisée par intelligence artificielle développé par DictIA Inc. Il permet de convertir des enregistrements audio en texte de manière automatisée, avec des fonctionnalités optionnelles de diarisation des locuteurs et de synthèse intelligente. **DictIA n'est pas un dispositif médical certifié** au sens de la *Loi sur les aliments et drogues* (L.R.C. 1985, c. F-27).
### 2.2 Fonctionnalités principales
- **Transcription automatisée** par WhisperX (BSD-2-Clause) ;
- **Diarisation vocale Niveau 1 (intra-session)** — identification des locuteurs au sein d'un même enregistrement, traitement en RAM uniquement, aucun stockage persistant, consentement distinct requis ;
- **Diarisation vocale Niveau 2 (inter-sessions)** — mémoire des locuteurs persistante, consentement distinct obligatoire ;
- **Résumé et structuration** par modèle de langage local — aucune donnée envoyée à des services d'IA externes ;
- **Gestion des transcriptions** : recherche, organisation, exportation et partage contrôlé.
### 2.3 Modes de déploiement
| Mode | Description | Infrastructure | Transfert hors Québec |
| --- | --- | --- | --- |
| DictIA 8 | Version locale — GPU 8&nbsp;Go | Poste de travail du Client | Aucun |
| DictIA 16 | Version locale — GPU 16&nbsp;Go | Poste de travail du Client | Aucun |
| DictIA Cloud | SaaS multi-tenant | OVH Beauharnois (Québec) + GCP Toronto (Ontario, RAM uniquement, ≤ 5 min) | Ontario (RAM, max. 5&nbsp;min) |
### 2.4 Architecture technique DictIA Cloud
- **Stockage persistant** : OVH Canada Inc., Beauharnois (Québec) — données chiffrées AES-256 au repos, juridiction québécoise ;
- **Traitement GPU** : Google Cloud Platform, région *northamerica-northeast2-b* (Toronto, Ontario) — traitement exclusivement en mémoire vive (RAM), aucune écriture sur disque, arrêt automatique après 5 minutes maximum, transfert hors Québec encadré par EFVP signée *EFVP_GCP* ;
- **Réseau sécurisé** : tunnel Tailscale VPN (WireGuard, ChaCha20-Poly1305) entre OVH Beauharnois QC et GCP Toronto ON ;
- **Frontend web** : Cloudflare CDN (donnees de navigation uniquement — EFVP signée *EFVP_Hubspot*). La plateforme applicative DictIA est accessible exclusivement via le tunnel Tailscale VPN, sans exposition publique.
**Engagement fondamental** : toutes les données personnelles persistantes des utilisateurs demeurent au Québec (OVH Beauharnois). Les données médicales et biométriques ne quittent jamais le Canada.
## 3. Inscription et compte utilisateur
L'inscription au Service requiert un consentement granulaire conforme à l'article 14 de la Loi 25. Quatre consentements distincts sont collectés :
1. Acceptation des présentes Conditions d'utilisation (obligatoire) ;
2. Acceptation de la Politique de confidentialité (obligatoire) ;
3. Consentement aux communications marketing (facultatif) ;
4. Consentement aux mesures d'analyse d'audience anonymisées (facultatif).
L'Utilisateur s'engage à fournir des informations exactes et à maintenir la confidentialité de ses identifiants. Les mots de passe sont stockés sous forme hachée (bcrypt). L'authentification multi-facteurs (MFA) est obligatoire pour tous les accès administrateurs.
## 4. Forfaits et prix
Trois formules sont proposées :
- **DictIA 8** : déploiement local single-user (boîtier livré au cabinet) ;
- **DictIA 16** : déploiement local multi-user (boîtier livré au cabinet) ;
- **DictIA Cloud** : SaaS hébergé chez OVH Beauharnois (Québec) avec traitement GPU temporaire RAM-only sur GCP Toronto (Ontario).
Les prix en vigueur sont publiés sur la page <https://dictia.ca/tarifs>. Les prix sont libellés en dollars canadiens (CAD) et excluent les taxes applicables (TPS et TVQ).
## 5. Modalités de paiement
Les paiements sont traités par **Stripe Inc.** (San Francisco CA, certifié PCI-DSS). DictIA Inc. ne stocke pas les numéros de carte complets (tokenisation PCI-DSS gérée par Stripe). Les modes de paiement acceptés incluent les cartes de crédit majeures, Apple Pay et Google Pay. Les abonnements peuvent être facturés mensuellement ou annuellement (avec une réduction de 15&nbsp;% sur le tarif annuel).
Les taxes applicables (TPS 5&nbsp;% et TVQ 9,975&nbsp;%) sont ajoutées au moment de la facturation conformément à la législation fiscale québécoise.
## 6. Activation du service
L'accès au Service est activé après confirmation du paiement initial. Pour DictIA 8 et DictIA 16, l'activation requiert également la livraison et la configuration du boîtier matériel chez l'Utilisateur (délai indicatif : 5 à 10 jours ouvrables).
## 7. Obligations de l'utilisateur
L'Utilisateur s'engage à utiliser DictIA conformément aux présentes CGU, à la législation applicable et aux bonnes pratiques professionnelles. Sont notamment interdits :
- Tout usage visant à traiter des enregistrements obtenus sans le consentement des personnes enregistrées ;
- Tout traitement d'enregistrements contenant des renseignements personnels de tiers à des fins incompatibles ;
- Toute tentative d'extraction, de rétro-ingénierie ou de contournement des modèles d'IA intégrés ;
- Tout usage contraire au *Code criminel* (L.R.C. 1985, c. C-46), notamment l'interception illégale de communications privées (art. 184).
**Responsabilité du Client — consentement des personnes enregistrées** : le Client, en qualité de responsable du traitement, déclare et garantit que pour chaque enregistrement soumis à DictIA, il a obtenu le consentement préalable de toutes les personnes dont la voix figure dans l'enregistrement, qu'il les a informées du traitement par IA, et — si la diarisation est activée — qu'il a obtenu leur consentement exprès et distinct au traitement biométrique conformément aux articles 44 et 45 de la LCCJTI. Le Client conserve les preuves de ces consentements pendant la durée du contrat et 3 ans après son expiration.
## 8. Données biométriques vocales (LCCJTI art. 44-45)
La fonctionnalité de **diarisation vocale** de DictIA implique l'extraction d'empreintes vocales (vecteurs biométriques) à partir des enregistrements audios. Ces empreintes constituent des **données biométriques au sens de l'article 44 de la LCCJTI** et des renseignements sensibles au sens de la Loi 25.
Avant toute activation de la diarisation, l'Utilisateur doit donner son consentement via une case à cocher distincte, séparée et non pré-cochée :
- **Consentement biométrique — Niveau 1 (intra-session)** : empreintes calculées en mémoire vive et détruites immédiatement à la fin du traitement, jamais stockées de façon persistante ;
- **Consentement biométrique — Niveau 2 (inter-sessions, mémoire des locuteurs)** : vecteurs persistants pseudonymisés stockés sur OVH Beauharnois (Québec), chiffrés AES-256, durée maximale de **12 mois** après la dernière utilisation. Banque biométrique déclarée à la CAI (formulaire K1 préparé, soumission préalable obligatoire).
Les empreintes vocales sont utilisées **exclusivement** pour la distinction et l'attribution des locuteurs dans les transcriptions. Elles ne sont en aucun cas utilisées pour la vérification ou la confirmation d'identité civile, la surveillance, le profilage, le pistage, le contrôle d'accès, ni pour l'entraînement, l'affinage ou l'évaluation de modèles d'IA.
## 9. Sous-traitants et transferts hors Québec
Conformément à l'article 17 de la *Loi sur le secteur privé*, DictIA Inc. a réalisé des EFVP documentées pour chaque transfert. Les sous-traitants autorisés sont :
| Sous-traitant | Service | Localisation | EFVP / DPA |
| --- | --- | --- | --- |
| OVH Canada Inc. | Stockage principal DictIA Cloud | Beauharnois (Québec) | DPA OVH (PIPA CA-1.0) signé |
| Google Cloud Platform | Traitement GPU temporaire (transcription, diarisation, LLM) | Toronto (Ontario) — RAM-only ≤ 5 min | EFVP *EFVP_GCP* signée + Cloud DPA GCP |
| Cloudflare Inc. | CDN web (dictia.ca) | États-Unis (réseau global) | EFVP *EFVP_Hubspot* signée |
| HubSpot Inc. | CRM, formulaires, courriel marketing | Cambridge MA (États-Unis) | EFVP *EFVP_Hubspot* signée + DPA HubSpot signé (SCC) |
| Stripe Inc. | Traitement des paiements (PCI-DSS) | États-Unis | DPA Stripe |
Aucune donnée audio, transcription, vecteur biométrique ni renseignement personnel sur la santé n'est transmis à HubSpot, Cloudflare ni Stripe. Les utilisateurs sont informés du transfert vers GCP Toronto (Ontario) dans la présente section et dans la Politique de confidentialité.
## 10. Engagement de non-entraînement IA
DictIA Inc. prend l'engagement ferme, public et documenté suivant :
> Les données audio, les transcriptions, les résumés et les vecteurs biométriques des utilisateurs et clients de DictIA ne sont **JAMAIS** utilisés pour entraîner, affiner (*fine-tuning*), évaluer ou améliorer les modèles d'intelligence artificielle exploités par DictIA Inc., ni pour développer de nouveaux modèles, ni pour être cédés à des tiers à ces fins.
Tous les modèles d'IA fonctionnent en mode **inférence uniquement** (inference-only). Aucun mécanisme d'apprentissage en ligne n'est actif.
## 11. Limitation de responsabilité et avis médical
DictIA est un outil d'**assistance à la transcription** et n'est pas un dispositif médical certifié. Les transcriptions générées peuvent contenir des erreurs ou des inexactitudes. Le professionnel de la santé est seul responsable de relire intégralement chaque transcription avant toute utilisation clinique, de la valider et de la corriger avant intégration dans un dossier médical, et d'exercer son jugement clinique de manière indépendante.
DictIA Inc. n'est pas responsable du contenu des enregistrements audio soumis, de l'exactitude des transcriptions automatisées, du non-respect par le Client de ses obligations en matière de consentement ou de protection des renseignements personnels, ni des dommages indirects, accessoires, consécutifs ou punitifs.
**Plafond de responsabilité** : la responsabilité totale de DictIA Inc. envers le Client ne pourra en aucun cas excéder le montant total des frais effectivement payés par le Client au cours des **douze (12) mois** précédant l'événement donnant lieu à la réclamation. Ce plafond ne s'applique pas en cas de faute lourde ou intentionnelle (art. 1474 C.c.Q.) ni aux réclamations fondées sur une violation des droits à la vie privée résultant d'un manquement grave aux obligations de sécurité ou de confidentialité.
## 12. Suspension et résiliation
Le Contrat est conclu pour la durée de l'abonnement choisi et renouvelé automatiquement sauf avis de résiliation. DictIA Inc. avise le Client par courriel **au moins 14 jours avant la date de renouvellement**.
Le Client peut résilier à tout moment en transmettant un avis écrit à <facturation@dictia.ca>. Dans les **30 jours** suivant la résiliation effective, DictIA Inc. fournit au Client ses données dans un format exploitable (TXT, DOCX, JSON) et supprime toutes les copies sous son contrôle.
DictIA Inc. peut résilier le Contrat&nbsp;: pour manquement grave après mise en demeure de 30 jours ; pour violation grave de la LSP ou de la LCCJTI (préavis de 10 jours) ; pour traitement manifestement illicite (effet immédiat) ; ou pour cessation d'activité (préavis de 60 jours).
DictIA Inc. peut **suspendre** l'accès au service pour non-paiement au-delà de 15 jours, utilisation contraire aux CGU, risque de sécurité imminent ou ordonnance d'une autorité compétente.
## 13. Propriété intellectuelle (AGPL-3.0 — open core)
Le logiciel DictIA est basé sur le projet open source **Speakr**, distribué sous licence **GNU Affero General Public License v3.0 (AGPL-3.0)**. Conformément à l'article 13 de la licence AGPL-3.0 :
- Le code source du logiciel DictIA est accessible aux utilisateurs du service à l'adresse <https://gitea.dictia.ca/Innova-AI/dictia-public>. DictIA Inc. s'engage à maintenir cette page opérationnelle en tout temps pendant la durée du service ;
- Les modifications apportées par DictIA Inc. au code source sont documentées dans le fichier `CHANGES.md` du référentiel ;
- L'attribution upstream à l'auteur originel du projet Speakr est préservée dans le fichier `NOTICE`.
**Valeur ajoutée propriétaire** (non couverte par AGPL-3.0)&nbsp;: services d'installation, de configuration et de déploiement ; infrastructure d'hébergement et de traitement cloud ; services de support technique et de maintenance ; personnalisations clients ; interfaces API propriétaires.
**Propriété des données du Client** : le Client est propriétaire de l'ensemble de ses données (enregistrements, transcriptions, résumés). DictIA Inc. n'acquiert aucun droit sur ces données.
Les marques « **DictIA** » et « **DictIA Inc.** » sont la propriété exclusive de DictIA Inc.
## 14. Modifications des conditions
DictIA Inc. peut modifier les présentes CGU pour refléter des changements législatifs, réglementaires, technologiques ou opérationnels. Toute modification substantielle est notifiée au Client par courriel et par notification dans l'interface, **avec un préavis minimum de 30 jours** avant l'entrée en vigueur. Si le Client refuse les nouvelles CGU, il peut résilier sans pénalité avant la date d'entrée en vigueur.
## 15. Droit applicable et juridiction
Les présentes CGU sont régies par les lois du Québec et du Canada applicables, notamment&nbsp;: la *Loi sur la protection des renseignements personnels dans le secteur privé* (RLRQ, c. P-39.1, telle que modifiée par la Loi 25), le *Code civil du Québec* (RLRQ, c. CCQ-1991), la *Loi concernant le cadre juridique des technologies de l'information* (RLRQ, c. C-1.1), la *Charte de la langue française* (RLRQ, c. C-11.1, telle que modifiée par la Loi 96), la *Loi anti-pourriel canadienne* (LCAP) et la *Loi sur la protection des renseignements personnels et les documents électroniques* (LPRPDE).
Tout litige est soumis à la compétence exclusive des tribunaux du **district judiciaire de Québec**, sous réserve des dispositions impératives d'ordre public.
**Force majeure** : aucune partie n'est responsable du retard ou de l'inexécution résultant d'un cas de force majeure au sens de l'article 1470 C.c.Q.
## 16. Langue
Le présent Contrat est rédigé en langue française conformément à la *Charte de la langue française* telle que modifiée par la Loi 96. En cas de traduction, la version française prévaut en tout temps. Toutes les communications officielles entre DictIA Inc. et le Client (notifications d'incidents, réponses DSAR, avis de modification, support technique) sont effectuées en français.
## 17. Contact
Pour toute question relative aux présentes Conditions :
- **Courriel général** : <info@dictia.ca>
- **Facturation et résiliation** : <facturation@dictia.ca>
- **Responsable de la protection des renseignements personnels (RPRP)** : <rprp@dictia.ca>
- **Téléphone** : 581 996-8471
- **Adresse postale** : DictIA Inc., 77 chemin de la Seigneurie, Inverness QC G0S 1K0
## 18. Date de mise à jour
Version 2026-04-27 — Inverness, Québec. Présentes CGU alignées sur le document signé *CGU-CLIENT_DictIA* (v1.0, 9 mars 2026).

View File

@@ -0,0 +1,230 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Ce document a valeur informative jusqu'à la revue finale par la responsable légale de DictIA Inc. La version contractuelle de référence est le document signé <code>GOUV_PDC_Politique_confidentialite_DictIA</code> (v1.0, 9 mars 2026).
</div>
DictIA Inc. attache la plus grande importance à la protection des renseignements personnels de ses utilisateurs. La présente Politique de confidentialité décrit, conformément à la *Loi sur la protection des renseignements personnels dans le secteur privé* (RLRQ, c. P-39.1, telle que modifiée par la *Loi 25*, L.Q. 2021, c. 25), à la *Loi concernant le cadre juridique des technologies de l'information* (RLRQ, c. C-1.1, ci-après « LCCJTI ») et à la *Loi sur la protection des renseignements personnels et les documents électroniques* (L.C. 2000, c. 5), les pratiques de collecte, d'utilisation, de conservation et de communication des renseignements personnels.
## 1. Identité du responsable
Le responsable du traitement des renseignements personnels est :
- **Raison sociale** : DictIA Inc.
- **Forme juridique** : société par actions constituée le 22 mars 2026 en vertu de la *Loi sur les sociétés par actions du Québec* (RLRQ, c. S-31.1)
- **NEQ** : 1181949562
- **Siège social** : 77 chemin de la Seigneurie, Inverness QC G0S 1K0, Canada
- **District judiciaire** : Québec
- **Actionnaires** : Allison Rioux (50&nbsp;%) et Jean-David Lévesque-Rioux (50&nbsp;%)
- **Téléphone** : 581 996-8471
- **Courriel général** : <info@dictia.ca>
- **Site web** : <https://dictia.ca>
## 2. Coordonnées du responsable de la protection des renseignements personnels (RPRP)
Conformément à l'article 3.1 de la *Loi sur le secteur privé*, DictIA Inc. a désigné une responsable de la protection des renseignements personnels :
- **Responsable** : Allison Rioux
- **Titre** : Chef de la direction (CEO) & Responsable de la protection des renseignements personnels (RPRP)
- **Courriel dédié** : <rprp@dictia.ca> (alias surveillé exclusivement par la fonction RPRP)
- **Téléphone** : 581 996-8471
- **Adresse postale** : Allison Rioux, RPRP — DictIA Inc., 77 chemin de la Seigneurie, Inverness QC G0S 1K0
- **Délai de réponse** : 30 jours suivant la réception de la demande (prorogeable de 10 jours avec avis écrit motivé)
Toute demande relative à la présente Politique, à l'exercice des droits Loi 25 ou à un incident de confidentialité doit être adressée à <rprp@dictia.ca>.
## 3. Renseignements personnels collectés
DictIA Inc. collecte et traite les catégories de renseignements personnels suivantes :
### 3.1 Données d'identification
Nom, prénom, adresse courriel, numéro de téléphone, adresse postale, nom de l'organisation et titre professionnel (le cas échéant), identifiants de compte (nom d'utilisateur, mot de passe haché en bcrypt — jamais stocké en clair).
### 3.2 Données audio
Enregistrements audio soumis par les utilisateurs pour transcription via DictIA. Ces enregistrements peuvent contenir la voix de l'utilisateur et de tiers.
### 3.3 Transcriptions
Textes transcrits générés automatiquement à partir des enregistrements audio, versions corrigées ou annotées, résumés et post-traitements générés par le modèle de langage local.
### 3.4 Données biométriques vocales
Vecteurs d'empreintes vocales (*voice embeddings*) extraits par le module de diarisation pyannote.audio. Ces empreintes constituent des **caractéristiques biométriques au sens des articles 44 et 45 de la LCCJTI** et des **renseignements sensibles au sens de la Loi 25** — soumises à des mesures de sécurité renforcées et à l'obligation de déclaration préalable à la Commission d'accès à l'information du Québec (CAI). La collecte et le traitement de ces données font l'objet d'un consentement distinct et explicite (voir sections 5 et 12).
### 3.5 Données d'utilisation et données techniques
Adresse IP, type de navigateur et système d'exploitation, pages consultées, date et heure de consultation, données de session, journaux d'utilisation de la plateforme (actions effectuées, paramètres de transcription, durée des sessions), données de performance et diagnostics techniques.
### 3.6 Données de paiement
Nom du titulaire du mode de paiement, informations de paiement tokenisées (les paiements sont traités par Stripe Inc., San Francisco CA, certifié PCI-DSS — DictIA Inc. ne stocke pas les numéros de cartes complets), adresse de facturation, historique des transactions.
## 4. Finalités du traitement
DictIA Inc. collecte et utilise les renseignements personnels uniquement aux fins suivantes :
- **Fourniture et exécution du service** : création et gestion des comptes, transcription automatique, diarisation vocale, post-traitement linguistique, support technique, facturation et gestion des paiements ;
- **Amélioration du service** : analyse des tendances d'utilisation, diagnostics techniques, développement de nouvelles fonctionnalités. **Important : DictIA Inc. n'utilise pas les enregistrements audio, les transcriptions ni les empreintes vocales des utilisateurs pour entraîner ses modèles d'intelligence artificielle, sauf consentement exprès et séparé** ;
- **Obligations légales et réglementaires** : respect des obligations fiscales, comptables et réglementaires, réponse aux demandes des autorités compétentes, tenue du registre des incidents de confidentialité ;
- **Sécurité** : prévention des accès non autorisés, détection et prévention des fraudes et incidents, journaux d'accès et de sécurité.
## 5. Base légale et consentement
Conformément aux articles 14 et suivants de la *Loi sur le secteur privé*, DictIA Inc. recueille un consentement **manifeste, libre, éclairé, spécifique et temporaire** avant toute collecte, utilisation ou communication de renseignements personnels.
Quatre consentements granulaires sont capturés et journalisés au moment de l'inscription :
1. Conditions d'utilisation (obligatoire pour la fourniture du Service) ;
2. Politique de confidentialité (obligatoire pour la fourniture du Service) ;
3. Communications marketing (facultatif, révocable à tout moment) ;
4. Mesures d'analyse d'audience anonymisées (facultatif, révocable à tout moment).
Un **consentement explicite, distinct et spécifique** (case à cocher non pré-cochée) est requis pour :
- Les **données biométriques vocales** (voir section 12) — art. 12 al. 3 *Loi sur le secteur privé* et art. 44 LCCJTI ;
- L'utilisation des données à des fins d'amélioration des modèles d'IA ;
- Toute communication de renseignements personnels à des tiers non nécessaire à l'exécution du service ;
- Tout transfert de renseignements personnels hors du Québec (voir section 7).
Le journal des consentements (`ConsentLog`) conserve la version exacte des documents acceptés, l'horodatage et l'adresse IP au moment du consentement. Toute personne peut retirer son consentement à tout moment en écrivant à <rprp@dictia.ca>, sous réserve des obligations légales de DictIA Inc.
**Responsabilité de l'utilisateur — consentement des tiers (art. 184.1 *Code criminel*)** : lorsque l'utilisateur soumet un enregistrement contenant la voix de tiers, il lui incombe d'obtenir leur consentement préalable au traitement de leurs données, incluant le traitement biométrique. DictIA Inc. met à disposition un formulaire de consentement biométrique (document I2).
## 6. Destinataires et sous-traitants techniques
DictIA Inc. **ne vend, ne loue et ne commercialise pas** les renseignements personnels de ses utilisateurs. DictIA Inc. fait appel aux sous-traitants techniques suivants :
| Sous-traitant | Service | Localisation des données | Encadrement |
| --- | --- | --- | --- |
| **OVH Hébergement Canada Inc.** | Stockage persistant principal de DictIA Cloud | Beauharnois (Québec, Canada) | DPA OVH (PIPA CA-1.0) signé + entente de sous-traitance art. 18.3 LSP |
| **Google Cloud Platform Inc.** | Traitement GPU (transcription, diarisation, inférence LLM) | Toronto (Ontario, Canada) — région *northamerica-northeast2-b* | EFVP signée *EFVP_GCP* + Cloud Data Processing Addendum GCP. Architecture **RAM-only** : aucune écriture sur disque, durée maximale 5 minutes par session, arrêt automatique. Données techniques en transit chiffrées via tunnel Tailscale VPN (WireGuard) entre OVH QC et GCP ON |
| **Cloudflare Inc.** | CDN du site institutionnel dictia.ca | États-Unis (réseau mondial) | EFVP signée *EFVP_Hubspot*. Données de navigation en transit uniquement (IP pseudonymisées, requêtes HTTP) — aucune donnée audio, transcription ni biométrique. La plateforme applicative DictIA n'est pas exposée via Cloudflare (accès restreint au tunnel Tailscale VPN) |
| **HubSpot Inc.** | CRM, gestion des contacts, formulaires web, courriel marketing | Cambridge (Massachusetts, États-Unis) | EFVP signée *EFVP_Hubspot* + DPA HubSpot signé (Standard Contractual Clauses incluses). Aucune donnée audio, transcription ni biométrique transmise |
| **Stripe Inc.** | Traitement des paiements en ligne | États-Unis (San Francisco CA) | DPA Stripe en vigueur. Certifié PCI-DSS. Données de paiement tokenisées — aucun numéro de carte complet stocké par DictIA Inc. |
Conformément à l'article 18.3 de la *Loi sur le secteur privé*, chaque entente de sous-traitance prévoit&nbsp;: les mesures de protection applicables, l'obligation d'utiliser les renseignements uniquement aux fins du mandat, l'obligation d'aviser DictIA Inc. de tout incident, et l'obligation de destruction ou restitution des renseignements à la fin du mandat.
DictIA Inc. peut communiquer des renseignements personnels sans consentement lorsque la loi l'exige, notamment à la CAI dans le cadre d'une enquête, à un tribunal ou organisme d'enquête, ou en cas de menace à la vie, à la santé ou à la sécurité d'une personne.
## 7. Transferts hors Québec
Conformément à l'article 17 de la *Loi sur le secteur privé*, DictIA Inc. a réalisé une **Évaluation des facteurs relatifs à la vie privée (EFVP)** documentée pour chaque transfert hors Québec. Les transferts autorisés sont :
| Destination | Sous-traitant | Nature des données | EFVP |
| --- | --- | --- | --- |
| Toronto, Ontario (Canada) | Google Cloud Platform | Audio et transcriptions traités en RAM uniquement (max. 5 min/session), aucune persistance sur disque | *EFVP_GCP* signée |
| Cambridge, MA (États-Unis) | HubSpot Inc. | Données de navigation et d'interaction CRM (nom, courriel, comportement web) — aucune donnée audio, transcription ni biométrique | *EFVP_Hubspot* signée |
| États-Unis (réseau global) | Cloudflare Inc. | Données techniques en transit (IP pseudonymisées, requêtes HTTP) — aucune donnée audio ni biométrique | *EFVP_Hubspot* signée |
| États-Unis (San Francisco CA) | Stripe Inc. | Données de paiement tokenisées — aucun numéro de carte complet | EFVP en cours |
**Engagement fondamental** : les données médicales et biométriques des utilisateurs de DictIA Cloud ne sont **jamais** transférées hors du Canada. Les données personnelles persistantes des utilisateurs résident sur OVH Beauharnois (Québec). Le traitement GPU temporaire en Ontario s'effectue exclusivement en mémoire vive, sans persistance.
DictIA Inc. ne transfère pas de renseignements personnels vers un territoire dont le régime juridique n'offre pas une protection équivalente à celle prévue par la loi québécoise, sauf en cas de nécessité, avec consentement explicite préalable, EFVP, entente écrite avec le destinataire et information de la personne concernée du nom du territoire et de la finalité.
## 8. Durée de conservation
Conformément à l'article 12 de la *Loi sur le secteur privé*, DictIA Inc. ne conserve les renseignements personnels que pour la durée nécessaire à la réalisation des finalités. Une fois la finalité réalisée, les renseignements sont détruits ou anonymisés de manière irréversible.
| Catégorie | Durée de conservation |
| --- | --- |
| Données d'identification (compte) | Durée du contrat de service + 30 jours (suppression anticipée possible par l'utilisateur) |
| Fichiers audio | 30 jours après traitement (par défaut) — extensible jusqu'à 12 mois sur choix explicite de l'utilisateur |
| Transcriptions et résumés IA | Durée de la relation contractuelle (suppression à tout moment par l'utilisateur ou via DSAR) |
| Empreintes vocales — diarisation **intra-session** | Destruction immédiate (purge RAM automatique à la fin du traitement, max. 5 min) — jamais stockées sur disque |
| Empreintes vocales — diarisation **inter-sessions** (opt-in) | Maximum **12 mois** après la dernière utilisation (chiffrées AES-256, stockées sur OVH Beauharnois QC) |
| Données de facturation (Stripe) | 7 ans après la dernière transaction (obligations fiscales LIR, LTVQ) |
| Consentements et preuves de consentement (`ConsentLog`) | Durée du contrat + 3 ans (prescription civile art. 2925 C.c.Q.) |
| Journaux d'accès (logs) | 12 mois |
| Métadonnées d'utilisation | 12 mois |
| Correspondance support client | Durée du contrat |
| Sauvegardes chiffrées (OVH Beauharnois QC) | 30 jours après suppression des données sources |
**Destruction sécurisée** : à l'expiration des durées, les données numériques sont détruites par effacement cryptographique conforme à la norme NIST SP 800-88 Rev. 1 ; les données biométriques font l'objet d'une destruction renforcée (effacement cryptographique avec écrasement supplémentaire et double vérification) ; les documents papier sont déchiquetés selon la norme DIN 66399 niveau P-4 minimum.
L'utilisateur peut, à tout moment, supprimer ses enregistrements audio, transcriptions et empreintes vocales directement depuis l'interface de la plateforme.
## 9. Droits de l'utilisateur
Conformément à la *Loi sur le secteur privé*, vous disposez des droits suivants :
- **Droit d'accès** (art. 27) : connaître l'existence et la nature des renseignements vous concernant ;
- **Droit de rectification** (art. 28) : faire corriger ou supprimer tout renseignement inexact, incomplet, équivoque ou non autorisé ;
- **Droit à la désindexation** (art. 28.1) : exiger la cessation de diffusion ou la désindexation d'un renseignement ;
- **Droit à la portabilité** (art. 27 al. 3) : recevoir les renseignements dans un format technologique structuré (CSV, JSON ou autre format standard) ou les faire transmettre à une autre entreprise ;
- **Droit de retrait du consentement** : à tout moment, sous réserve des obligations légales (sans effet rétroactif) ;
- **Droit d'être informé des décisions automatisées** (art. 12.1) : obtenir les renseignements utilisés, les facteurs et paramètres ayant mené à la décision, et faire réviser la décision par une personne physique compétente ;
- **Droit d'être avisé en cas d'incident de confidentialité** présentant un risque de préjudice sérieux (art. 3.5).
**Procédure** : adresser la demande à <rprp@dictia.ca> ou par courrier postal à l'adresse indiquée à la section 2. DictIA Inc. accuse réception et répond dans les **30 jours** suivant la réception (art. 32 *Loi sur le secteur privé*), prorogeable de 10 jours dans les cas complexes avec avis écrit motivé.
DictIA Inc. n'exige pas de frais pour répondre à une demande d'accès ou de rectification, sauf frais raisonnables annoncés à l'avance. Une vérification d'identité est effectuée avant tout traitement.
## 10. Décisions automatisées
La plateforme DictIA utilise les traitements automatisés suivants, exécutés en mode inférence uniquement sur l'infrastructure de DictIA Inc. (aucune donnée envoyée à des services d'IA externes) :
| Traitement | Technologie | Description |
| --- | --- | --- |
| Transcription automatique | WhisperX (BSD-2-Clause) | Conversion automatique de la parole en texte écrit |
| Diarisation vocale | pyannote.audio | Identification et distinction automatique des locuteurs dans un enregistrement |
| Post-traitement linguistique | Modèle de langage local (Mistral, Apache 2.0, ou autre modèle configurable) | Correction grammaticale, ponctuation, mise en forme et résumé optionnel |
Les transcriptions et diarisations constituent des **outils d'aide** : elles ne produisent pas, à elles seules, de décisions juridiques ou administratives à l'égard des utilisateurs. Conformément à l'article 12.1 de la *Loi sur le secteur privé*, vous pouvez exercer vos droits relatifs aux décisions automatisées en communiquant avec le RPRP.
## 11. Procédure de plainte
Si vous estimez que DictIA Inc. ne respecte pas ses obligations en matière de protection des renseignements personnels, vous pouvez déposer une plainte auprès de la **Commission d'accès à l'information du Québec (CAI)** :
- **Site web** : <https://www.cai.gouv.qc.ca>
- **Courriel** : <cai.communications@cai.gouv.qc.ca>
- **Téléphone (Québec)** : 418 528-7741
- **Téléphone (Montréal)** : 514 873-4196
- **Sans frais** : 1 888 528-7741
- **Adresse** : 525 boulevard René-Lévesque Est, bureau 2.36, Québec (Québec) G1R 5S9
## 12. Données biométriques (LCCJTI art. 44-45)
La fonctionnalité de **diarisation vocale** de DictIA repose sur l'extraction de vecteurs d'empreintes vocales (*voice embeddings*) par pyannote.audio. Ces vecteurs constituent des **caractéristiques biométriques** au sens des articles 44 et 45 de la LCCJTI et des **renseignements sensibles** au sens de la Loi 25 — à ce titre, ils bénéficient de mesures de protection renforcées.
DictIA offre **deux niveaux** de traitement biométrique :
| Niveau | Description | Stockage | Consentement requis |
| --- | --- | --- | --- |
| **Diarisation intra-session** | Identification des locuteurs pour la session en cours uniquement | Aucun — traitement en RAM, destruction immédiate à la fin de la session | Standard (consentement biométrique distinct, niveau 1) |
| **Diarisation inter-sessions** (« mémoire des locuteurs ») | Reconnaissance vocale persistante entre plusieurs sessions | OVH Beauharnois QC — chiffré AES-256 — max. **12 mois** après dernière utilisation | Consentement exprès et distinct obligatoire (case à cocher non pré-cochée — formulaire I2) |
Conformément à la LCCJTI :
- **Consentement exprès préalable** (art. 44 LCCJTI) recueilli via case à cocher distincte non pré-cochée ;
- **Déclaration à la CAI** (art. 44 LCCJTI) — DictIA Inc. a préparé et soumettra une déclaration de création d'une banque de caractéristiques biométriques (formulaire CAI K1) **au moins 60 jours avant l'activation** de la diarisation inter-sessions. Aucune collecte d'empreintes vocales persistantes n'aura lieu avant la réception de l'accusé de réception de la CAI ;
- **Finalité limitée** — usage exclusif&nbsp;: distinction des locuteurs (diarisation). Aucune utilisation à des fins d'identification, d'authentification, de surveillance ou d'entraînement de modèles d'IA ;
- **Conservation minimale** (art. 45 LCCJTI) — destruction irréversible après 12 mois maximum ; demande de destruction anticipée possible à tout moment ;
- **Destruction renforcée** — effacement cryptographique avec écrasement supplémentaire et double vérification.
**Droit de refus** : le refus de la diarisation inter-sessions n'empêche pas l'utilisation du service de transcription de base.
## 13. Sécurité
DictIA Inc. met en œuvre les mesures de sécurité suivantes :
- **Chiffrement au repos** : AES-256 (toutes les données stockées sur OVH Beauharnois) ;
- **Chiffrement en transit** : TLS 1.3 ou supérieur + tunnel Tailscale VPN (WireGuard, ChaCha20-Poly1305) entre OVH QC et GCP ON ;
- **Réseau zéro-confiance** : aucun port exposé publiquement ;
- **Authentification** : mots de passe hachés bcrypt + authentification multifacteur disponible (TOTP, WebAuthn / passkeys) — MFA obligatoire pour les administrateurs ;
- **Contrôle d'accès** : RBAC selon le principe du moindre privilège ;
- **Journalisation** : journaux d'accès immuables (append-only) ;
- **Sauvegardes** : quotidiennes, chiffrées AES-256, rétention 30 jours, stockées au Québec ;
- **Mesures organisationnelles** : politique interne de protection des renseignements personnels (GOUV_PPRP signée), ententes de confidentialité avec employés et sous-traitants, formation, audits périodiques, procédure structurée de gestion des incidents conforme au *Règlement sur les incidents de confidentialité*.
En cas d'**incident de confidentialité** présentant un risque de préjudice sérieux, DictIA Inc. avise la CAI avec diligence (art. 3.5 LSP), avise les personnes concernées et tient un registre des incidents.
## 14. Cookies et traceurs
DictIA utilise un nombre limité de témoins de connexion, décrits en détail dans la [Politique de cookies](/legal/cookies). Catégories utilisées sur dictia.ca :
- **Témoins essentiels** (sans consentement) : session, jeton anti-CSRF, mémorisation du choix de consentement ;
- **Témoins Cloudflare** (sans consentement, sécurité essentielle) : protection CDN, filtrage du trafic, sécurité des requêtes HTTP ;
- **Témoins de performance et fonctionnels** (consentement préalable) : analyse de la fréquentation, mémorisation des préférences ;
- **Témoins HubSpot** (consentement préalable) : CRM, suivi des interactions visiteurs, formulaires de contact, courriel marketing.
## 15. Modifications à la présente politique
DictIA Inc. se réserve le droit de modifier la présente politique pour tenir compte des évolutions législatives, réglementaires ou technologiques. En cas de modification substantielle, DictIA Inc. publiera la version mise à jour avec la date de révision, informera les utilisateurs au moins **30 jours avant l'entrée en vigueur** des modifications, et recueillera un nouveau consentement lorsque requis (notamment pour les modifications portant sur les finalités du traitement biométrique).
## 16. Date de mise à jour
Version 2026-04-27 — Inverness, Québec. Politique alignée sur le document signé *GOUV_PDC_Politique_confidentialite_DictIA* (v1.0, 9 mars 2026, signé par Allison Rioux et Jean-David Lévesque-Rioux).

View File

@@ -0,0 +1,59 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Politique alignée sur la Politique de confidentialité signée <code>GOUV_PDC</code> (v1.0, 9 mars 2026).
</div>
## 1. Que sont les cookies
Un témoin de connexion (cookie) est un petit fichier texte qu'un site web dépose sur votre navigateur lorsque vous le visitez. Les cookies permettent au site de mémoriser vos préférences, de maintenir votre session ouverte et — lorsqu'autorisés — de mesurer la fréquentation du site.
DictIA Inc. (NEQ 1181949562, 77 chemin de la Seigneurie, Inverness QC G0S 1K0) utilise un nombre limité de témoins, dans le strict respect de la *Loi 25* du Québec.
## 2. Catégories de témoins utilisés sur dictia.ca
| Type de témoin | Finalité | Durée | Consentement |
| --- | --- | --- | --- |
| **Témoins essentiels** (`session`, `csrf_token`, `cookie_consent`) | Fonctionnement du site, gestion de session, sécurité, mémorisation des choix de consentement | Durée de la session ou 12 mois | Non requis (nécessaires au service) |
| **Témoins Cloudflare** | Protection CDN, filtrage du trafic, sécurité des requêtes HTTP | Durée de la session | Non requis (sécurité essentielle) |
| **Témoins de performance** | Analyse de la fréquentation et des tendances d'utilisation du site | Jusqu'à 13 mois | Consentement préalable (opt-in) |
| **Témoins fonctionnels** | Mémorisation des préférences de l'utilisateur (langue, paramètres d'affichage) | Jusqu'à 12 mois | Consentement préalable (opt-in) |
| **Témoins HubSpot** | CRM, suivi des interactions visiteurs, formulaires de contact, courriel marketing | Jusqu'à 13 mois | Consentement préalable (opt-in) |
Vous pouvez gérer vos préférences en matière de témoins à tout moment via le bandeau de consentement présenté lors de votre première visite ou dans les paramètres de votre navigateur. Le refus des témoins non essentiels n'affecte pas l'accès aux fonctionnalités de base du site.
## 3. Sous-traitants de cookies — transferts hors Québec
Les témoins non essentiels impliquent des sous-traitants situés hors du Québec :
- **Cloudflare Inc.** (États-Unis) — réseau de distribution de contenu (CDN) et protection du trafic web. Données techniques en transit uniquement (adresses IP pseudonymisées, requêtes HTTP). Aucune donnée audio, transcription ni biométrique. Transfert encadré par l'EFVP signée *EFVP_Hubspot*.
- **HubSpot Inc.** (Cambridge, Massachusetts, États-Unis) — plateforme CRM et marketing. Collecte des données de navigation, d'interaction avec le site et de gestion des contacts. Transfert encadré par l'EFVP signée *EFVP_Hubspot* et le DPA HubSpot signé (Standard Contractual Clauses incluses).
Ces transferts sont conformes à l'article 17 de la *Loi sur le secteur privé* (Loi 25). Voir la [Politique de confidentialité](/legal/confidentialite#section-7) pour les détails.
## 4. Aucun cookie publicitaire ni trackers tiers non documentés
DictIA s'engage à **ne jamais** déposer ou autoriser :
- Cookies publicitaires (Google Ads, Meta Pixel, TikTok Pixel, etc.) ;
- Cookies de réseaux sociaux non-listés (boutons de partage tiers, tracking *like*) ;
- Trackers de profilage cross-site (Hotjar, FullStory, etc.) ;
- *Fingerprinting* du navigateur ou de l'appareil.
## 5. Comment gérer vos cookies
Vous pouvez à tout moment :
- **Modifier vos consentements** via le bandeau de consentement (revoir vos choix), ou dans la console DictIA &rarr; Paramètres &rarr; Confidentialité ;
- **Bloquer les cookies** via les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge) ;
- **Supprimer les cookies déjà déposés** via les outils de votre navigateur (cela peut entraîner une déconnexion automatique de votre session DictIA).
Le blocage des témoins essentiels rendra le Service inutilisable. Le blocage des témoins non essentiels n'affecte pas l'utilisation du Service.
## 6. Pour aller plus loin
Pour une description détaillée du traitement des renseignements personnels par DictIA, consultez la [Politique de confidentialité (Loi 25)](/legal/confidentialite).
Pour toute question sur la présente Politique de cookies, écrivez à <rprp@dictia.ca>.
## 7. Date de mise à jour
Version 2026-04-27 — Inverness, Québec.

View File

@@ -0,0 +1,73 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Ce document a valeur informative jusqu'à la revue finale par la responsable légale de DictIA Inc.
</div>
## 1. Identité de l'éditeur
Le présent site web et l'ensemble du Service DictIA sont édités par :
- **Raison sociale** : DictIA Inc.
- **Forme juridique** : société par actions constituée le 22 mars 2026 en vertu de la *Loi sur les sociétés par actions du Québec* (RLRQ, c. S-31.1).
- **Actionnaires** : Allison Rioux (50&nbsp;%), Chef de la direction (CEO), et Jean-David Lévesque-Rioux (50&nbsp;%), Chef des technologies (CTO).
## 2. Adresse du siège social
77 chemin de la Seigneurie
Inverness QC G0S 1K0
Canada
District judiciaire&nbsp;: Québec
## 3. Numéro d'entreprise du Québec (NEQ)
Numéro d'entreprise du Québec (NEQ) : **1181949562**
## 4. Représentantes et représentants légaux
- **Présidente, Chef de la direction (CEO) et co-fondatrice** : Allison Rioux — courriel <info@dictia.ca>
- **Chef des technologies (CTO) et co-fondateur** : Jean-David Lévesque-Rioux — courriel <jd@dictia.ca>
## 5. Responsable de la publication
- **Responsable de la publication du site dictia.ca** : Allison Rioux, présidente
- **Responsable de la protection des renseignements personnels (RPRP)** : Allison Rioux — courriel dédié <rprp@dictia.ca>
- **Désignation officielle RPRP** : adoptée par les associés de DictIA Inc. le 9 mars 2026 conformément à l'article 3.1 de la *Loi sur la protection des renseignements personnels dans le secteur privé* (RLRQ, c. P-39.1).
## 6. Hébergement et infrastructure
L'infrastructure DictIA Cloud combine deux fournisseurs :
- **Stockage persistant** : OVH Hébergement Canada Inc. — centre de données de Beauharnois (Québec). Toutes les données utilisateur stockées de façon persistante résident en sol québécois. Ententes en vigueur&nbsp;: DPA OVH (PIPA CA-1.0) et entente de sous-traitance conforme à l'art. 18.3 LPRPSP.
- **Traitement GPU temporaire** : Google Cloud Platform Inc., région *northamerica-northeast2-b* (Toronto, Ontario). Traitement exclusivement en mémoire vive (RAM), aucune écriture sur disque, durée maximale de 5 minutes par session, arrêt automatique après traitement. Ce transfert hors Québec est encadré par l'EFVP signée *EFVP_GCP_EFVP_Transfert_GCP_Toronto* (mars 2026) et le Cloud Data Processing Addendum de Google Cloud.
- **Réseau zéro-confiance** : tunnel Tailscale VPN (WireGuard, ChaCha20-Poly1305) entre OVH Beauharnois et GCP Toronto — la plateforme applicative n'est pas exposée sur l'Internet public.
Coordonnées OVH Hébergement Canada Inc. :
- 800 boulevard de Maisonneuve Est, Montréal QC H2L 4M5, Canada
- Site web : <https://www.ovhcloud.com/fr-ca>
## 7. Crédits et propriété intellectuelle
Le code source de DictIA est publié sous la licence **GNU Affero General Public License v3.0 (AGPL-3.0)**.
DictIA est un *fork* du projet open source **Speakr** distribué sous AGPL-3.0. Conformément à l'article 13 de la licence AGPL-3.0, le code source complet de DictIA, incluant les modifications apportées par DictIA Inc. et documentées dans le fichier `CHANGES.md`, est accessible aux utilisateurs du Service. L'attribution upstream à l'auteur originel du projet Speakr est intégralement préservée dans le fichier `NOTICE` du dépôt public.
- **Dépôt public DictIA (Gitea)** : <https://gitea.dictia.ca/Innova-AI/dictia-public>
- **Notice d'attribution upstream** : voir le fichier `/NOTICE` à la racine du dépôt
- **Modifications versionnées** : voir le fichier `/CHANGES.md`
Modèles d'intelligence artificielle exploités en mode inférence uniquement&nbsp;: WhisperX (BSD-2-Clause), pyannote.audio (licence Enterprise pour usage SaaS commercial) et un modèle de langage local (Mistral, Apache 2.0, ou autre modèle configurable). Aucun de ces modèles n'est entraîné, affiné ou évalué sur les données des utilisateurs.
Les marques « **DictIA** » et « **DictIA Inc.** » et leurs logos sont la propriété exclusive de DictIA Inc.
## 8. Contact
Pour toute question relative aux présentes Mentions légales :
- **Courriel général** : <info@dictia.ca>
- **Courriel RPRP (Loi 25)** : <rprp@dictia.ca>
- **Téléphone** : 581 996-8471
- **Adresse postale** : DictIA Inc., 77 chemin de la Seigneurie, Inverness QC G0S 1K0
## 9. Date de mise à jour
Version 2026-04-27 — Inverness, Québec.

View File

@@ -0,0 +1,59 @@
<div class="legal-draft-callout" role="note" aria-label="Document en cours de révision juridique">
<strong>BROUILLON v1.0</strong> &mdash; en attente de revue juridique par Allison Rioux. Ce document a valeur informative jusqu'à la revue finale par la responsable légale de DictIA Inc.
</div>
DictIA Inc. (NEQ 1181949562) souhaite que chaque utilisateur trouve réellement de la valeur dans le Service. La présente Politique de remboursement précise les modalités de retour, de résiliation et de remboursement applicables aux abonnements et au matériel DictIA.
## 1. Délai de rétractation — 14 jours
Conformément à l'article 1392 du *Code civil du Québec* (vente à distance) et à la *Loi sur la protection du consommateur*, vous disposez d'un **délai de rétractation de 14 jours calendaires** à compter de la date de la première facture pour annuler votre abonnement et obtenir un remboursement intégral.
Aucun motif n'est requis et aucune pénalité n'est appliquée pendant ce délai.
## 2. Remboursement au prorata après le délai de rétractation
Pour les abonnements **mensuels** résiliés après le délai de 14 jours, le service reste actif jusqu'à la fin de la période payée — aucun remboursement au prorata n'est accordé sur le mois en cours.
Pour les abonnements **annuels** résiliés après le délai de 14 jours, un remboursement au prorata est accordé sur les mois pleins restants (les fractions de mois ne sont pas remboursées). Exemple : un abonnement annuel résilié au bout de 7,5 mois donnera lieu à un remboursement de 4 mois.
## 3. Matériel DictIA 8 et DictIA 16
Pour les boîtiers matériels **DictIA 8** et **DictIA 16** :
- **0 à 30 jours** après la livraison : retour accepté avec remboursement intégral, à condition que le matériel soit retourné dans son emballage d'origine, en parfait état de marche, et accompagné de tous les accessoires.
- **Au-delà de 30 jours** : aucun remboursement du matériel. La garantie limitée du fabricant (12 mois pièces et main-d'œuvre) reste applicable.
Les frais de retour sont à la charge du client, sauf si le matériel s'est avéré défectueux à la réception (dans ce cas, DictIA fournit une étiquette de retour prépayée).
## 4. Procédure de remboursement
Pour demander un remboursement, écrivez à <info@dictia.ca> en précisant :
1. Votre adresse courriel de compte DictIA ;
2. Le numéro de la facture concernée (visible dans votre console DictIA &rarr; Facturation) ;
3. Le motif de la demande (facultatif, mais utile pour notre amélioration continue) ;
4. Pour le matériel : numéro de série du boîtier et description de l'état.
## 5. Délai de traitement
Les demandes de remboursement sont traitées dans un **délai de 14 jours ouvrables** à compter de leur réception complète. Le remboursement est effectué via le mode de paiement initial (carte de crédit, Apple Pay, Google Pay) par l'intermédiaire de Stripe Inc. (San Francisco CA, certifié PCI-DSS).
Les délais de visibilité sur le relevé bancaire dépendent de votre établissement (généralement 5 à 10 jours ouvrables supplémentaires).
## 6. Résiliation sans remboursement
L'utilisateur peut résilier son abonnement à tout moment, sans avoir à demander de remboursement. Dans ce cas, le service reste accessible jusqu'à la fin de la période payée, puis le compte passe en mode lecture seule pendant 30 jours avant suppression définitive.
## 7. Litiges
En cas de désaccord persistant sur un remboursement, le client peut s'adresser à l'**Office de la protection du consommateur du Québec** :
- **Site web** : <https://www.opc.gouv.qc.ca>
- **Téléphone** : 1 888 672-2556
- **Adresse** : 400 boul. Jean-Lesage, bureau 450, Québec QC G1K 8W4
Les litiges non résolus relèvent de la compétence exclusive des tribunaux du district judiciaire de Québec, conformément aux Conditions d'utilisation.
## 8. Date de mise à jour
Version 2026-04-27 — Inverness, Québec.

213
src/legal/routes.py Normal file
View File

@@ -0,0 +1,213 @@
"""Legal pages — 6 markdown-rendered pages (B-2.9).
Each page extends templates/legal/_layout.html and is publicly indexable
(see src/app.py:_PUBLIC_INDEXABLE_PREFIXES = ('marketing.', 'legal.')).
"""
from pathlib import Path
import markdown
from flask import abort, render_template
from src.legal import LEGAL_VERSION, legal_bp
CONTENT_DIR = Path(__file__).parent / 'content'
# All slugs that have a markdown file rendered by this blueprint.
VALID_PAGES = (
'conditions',
'confidentialite',
'cookies',
'remboursement',
'accessibilite',
'mentions',
)
# Display order on the index page (mentions kept in VALID_PAGES but
# rendered last on /legal/ — purely a presentation choice).
DISPLAY_ORDER = (
'conditions',
'confidentialite',
'cookies',
'remboursement',
'accessibilite',
'mentions',
)
PAGE_TITLES = {
'conditions': "Conditions d'utilisation",
'confidentialite': "Politique de confidentialité (Loi 25)",
'cookies': "Politique de cookies",
'remboursement': "Politique de remboursement",
'accessibilite': "Déclaration d'accessibilité (WCAG 2.2 AA)",
'mentions': "Mentions légales",
}
PAGE_DESCRIPTIONS = {
'conditions': "Conditions d'utilisation du service DictIA — droits, obligations, responsabilités.",
'confidentialite': "Politique de confidentialité conforme à la Loi 25 du Québec — collecte, conservation, droits des utilisateurs.",
'cookies': "Utilisation des cookies et traceurs sur les sites DictIA.",
'remboursement': "Politique de remboursement des abonnements DictIA.",
'accessibilite': "Engagement DictIA en matière d'accessibilité numérique (WCAG 2.2 AA).",
'mentions': "Mentions légales — DictIA Inc. (filiale d'InnovA AI S.E.N.C.).",
}
# Inline SVG icons (semantic, brand-colored). Each opens with a 24x24 viewBox.
PAGE_ICONS = {
'conditions': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>'
'<polyline points="14 2 14 8 20 8"/>'
'<line x1="9" y1="13" x2="15" y2="13"/>'
'<line x1="9" y1="17" x2="15" y2="17"/>'
'</svg>'
),
'confidentialite': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>'
'<polyline points="9 12 11 14 15 10"/>'
'</svg>'
),
'cookies': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>'
'<circle cx="8.5" cy="10.5" r="0.6" fill="currentColor"/>'
'<circle cx="13" cy="14" r="0.6" fill="currentColor"/>'
'<circle cx="16" cy="9" r="0.6" fill="currentColor"/>'
'<circle cx="9" cy="15.5" r="0.6" fill="currentColor"/>'
'</svg>'
),
'remboursement': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<path d="M21 12V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-1"/>'
'<path d="M16 12h5"/>'
'<circle cx="17" cy="12" r="2"/>'
'<path d="M3 9l4-4 2 2"/>'
'</svg>'
),
'accessibilite': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<circle cx="12" cy="12" r="10"/>'
'<circle cx="12" cy="6.5" r="1.2" fill="currentColor"/>'
'<path d="M5 9h14"/>'
'<path d="M12 9v4l-3 6"/>'
'<path d="M12 13l3 6"/>'
'</svg>'
),
'mentions': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<rect x="3" y="4" width="18" height="16" rx="2"/>'
'<line x1="7" y1="9" x2="17" y2="9"/>'
'<line x1="7" y1="13" x2="17" y2="13"/>'
'<line x1="7" y1="17" x2="13" y2="17"/>'
'</svg>'
),
'agpl': (
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<polyline points="16 18 22 12 16 6"/>'
'<polyline points="8 6 2 12 8 18"/>'
'<line x1="14" y1="4" x2="10" y2="20"/>'
'</svg>'
),
}
# External links surfaced on the legal index alongside the markdown pages.
EXTERNAL_LINKS = (
{
'slug': 'agpl',
'title': 'Code source AGPL',
'description': "Code source de DictIA publié sous licence AGPL-3.0 — conformité art. 13 de la licence.",
'url': 'https://gitea.dictia.ca',
'external': True,
},
)
def _render_markdown(page: str) -> str:
"""Read the markdown file for `page` and return rendered HTML."""
md_path = CONTENT_DIR / f'{page}.md'
if not md_path.exists():
abort(404)
raw = md_path.read_text(encoding='utf-8')
return markdown.markdown(
raw,
extensions=['toc', 'tables', 'fenced_code', 'attr_list'],
output_format='html5',
)
def _neighbour(slug: str, offset: int):
"""Return the (slug, title) of the previous/next page in DISPLAY_ORDER."""
if slug not in DISPLAY_ORDER:
return None, None
idx = DISPLAY_ORDER.index(slug) + offset
if idx < 0 or idx >= len(DISPLAY_ORDER):
return None, None
neighbour_slug = DISPLAY_ORDER[idx]
return neighbour_slug, PAGE_TITLES[neighbour_slug]
@legal_bp.route('/<page>')
def legal_page(page):
"""Render one of the 6 legal pages by slug."""
if page not in VALID_PAGES:
abort(404)
prev_slug, prev_title = _neighbour(page, -1)
next_slug, next_title = _neighbour(page, +1)
return render_template(
'legal/_layout.html',
title=PAGE_TITLES[page],
description=PAGE_DESCRIPTIONS[page],
content=_render_markdown(page),
page=page,
legal_version=LEGAL_VERSION,
prev_page=prev_slug,
prev_title=prev_title,
next_page=next_slug,
next_title=next_title,
)
@legal_bp.route('/')
def legal_index():
"""Index page listing all internal legal pages plus external links."""
pages = [
{
'slug': slug,
'title': PAGE_TITLES[slug],
'description': PAGE_DESCRIPTIONS[slug],
'icon': PAGE_ICONS.get(slug, ''),
'external': False,
}
for slug in DISPLAY_ORDER
] + [
{
'slug': link['slug'],
'title': link['title'],
'description': link['description'],
'icon': PAGE_ICONS.get(link['slug'], ''),
'url': link['url'],
'external': True,
}
for link in EXTERNAL_LINKS
]
return render_template(
'legal/index.html',
title="Documents légaux DictIA",
description="Index des documents légaux DictIA — conditions, confidentialité, cookies, remboursement, accessibilité, mentions, code source AGPL.",
pages=pages,
legal_version=LEGAL_VERSION,
)

16
src/marketing/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""Marketing blueprint - landing pages, public content, SEO/GEO assets.
Mounted at root "/" (no url_prefix). Coexists with the legacy /api/* and /app/*
blueprints. Routes added incrementally in Phase 2 (Tasks A-2.x).
"""
from flask import Blueprint
marketing_bp = Blueprint(
'marketing',
__name__,
template_folder='../../templates/marketing',
static_folder=None,
)
# Import routes module so it registers route handlers via decorators
from . import routes # noqa: E402,F401

122
src/marketing/routes.py Normal file
View File

@@ -0,0 +1,122 @@
"""Marketing routes — Phase 2 templated landing.
Phase 2 (A-2.1+): renders templates/marketing/landing.html.
Tasks A-2.2 through A-2.7 will progressively enrich the landing template.
Tasks A-2.8a + A-2.8b added /tarifs, /fonctionnalites, /conformite, /contact.
"""
from flask import render_template
from . import marketing_bp
# Pre-launch placeholder testimonials — T-4.1 will replace these with real
# pilot-client interviews (avocat + CPA + municipalité) in mai-juin 2026.
# Until then, render placeholder cards (LPC art. 219: no fabricated quotes).
TESTIMONIALS = [
{
'persona': 'avocat',
'placeholder_label': 'Cabinet juridique pilote',
'expected': 'Mai 2026',
},
{
'persona': 'cpa',
'placeholder_label': 'Cabinet CPA pilote',
'expected': 'Mai 2026',
},
{
'persona': 'municipal',
'placeholder_label': 'Municipalité pilote',
'expected': 'Juin 2026',
},
]
# FAQ — 10 verifiable Q&A enrichies depuis Website-Sanity/components/sections/dictai-page-content.tsx
# (round 3 — synchronisation avec source canonique production dictia.ca/solutions/dictai).
# Chaque question/réponse doit rester factuellement défendable (LPC art. 219).
FAQ = [
{
'q': 'Comment fonctionne la transcription?',
'a': 'DictIA utilise WhisperX Large-v3, le moteur de transcription de pointe d\'OpenAI, exécuté soit sur un GPU dédié au Québec (forfaits Cloud BASIC, ESSENTIEL, PRO — OVH Beauharnois) soit directement sur votre GPU local (DictIA&nbsp;LOCAL — RTX 5070&nbsp;Ti chez vous). Vous téléversez un fichier audio ou vidéo, et la transcription est générée automatiquement avec identification des locuteurs. Pour la conformité Loi&nbsp;25, l\'audit trail (art.&nbsp;3.5 LPRPSP), le registre des consentements (art.&nbsp;14) et l\'EFVP (art.&nbsp;3.3) sont fournis par défaut.',
},
{
'q': 'Quels formats audio/vidéo sont supportés?',
'a': 'DictIA accepte tous les formats courants&nbsp;: MP3, WAV, M4A, FLAC, OGG, MP4, MKV, WEBM, et plus encore. Aucune conversion préalable nécessaire. Les exports natifs incluent DOCX, PDF, SRT, VTT, TXT, JSON et MD. Modèles spécifiques disponibles pour avocats (interrogatoire numéroté), notaires (procès-verbal d\'assemblée) et CPA (transcription d\'entrevue).',
},
{
'q': 'Combien de temps pour transcrire 1&nbsp;heure d\'audio?',
'a': 'Environ 2&nbsp;minutes sur GPU. C\'est 99&nbsp;% plus rapide que la transcription manuelle, qui prend typiquement 4 à 6&nbsp;heures pour 1&nbsp;heure d\'audio. La précision typique observée sur nos jeux de tests internes dépasse 95&nbsp;% en français canadien. Méthodologie complète disponible sur demande&nbsp;: <a href="mailto:info@dictia.ca" class="grad-text underline">info@dictia.ca</a>.',
},
{
'q': 'La transcription est-elle vraiment confidentielle?',
'a': 'Avec DictIA&nbsp;LOCAL, vos données ne quittent jamais votre bureau — le traitement est 100&nbsp;% local, sans connexion internet requise. Avec les forfaits Cloud (BASIC, ESSENTIEL, PRO), les données sont hébergées exclusivement au Québec (OVH Beauharnois). Aucun transfert hors-frontières, zéro Cloud Act.',
},
{
'q': 'Teams Copilot est-il légal pour mes réunions?',
'a': 'Non. Teams Copilot envoie les transcriptions vers des serveurs Microsoft soumis au Cloud Act américain. La Loi&nbsp;25 (art.&nbsp;44-45) exige un consentement explicite pour transmettre des données biométriques (voix) hors du Québec. Depuis septembre&nbsp;2023, toute transcription sur Teams Copilot est en violation — sans exception.',
},
{
'q': 'Otter.ai est-il en violation?',
'a': 'Oui. Otter.ai héberge les données sur AWS us-east-1 (Virginie, USA). Vos enregistrements de réunions — y compris les discussions confidentielles avec vos clients — transitent et sont stockés sur des serveurs américains soumis au Cloud Act. C\'est une violation de la Loi&nbsp;25 depuis septembre&nbsp;2023.',
},
{
'q': 'Que dit le Barreau du Québec sur l\'IA?',
'a': 'En octobre&nbsp;2024, le Barreau a émis une directive interdisant explicitement l\'utilisation d\'outils IA qui envoient des données client vers des serveurs étrangers. Une violation peut entraîner des sanctions disciplinaires. DictIA est conçu comme une solution conforme au Code de déontologie du Barreau (architecture mappée — voir notre page <a href="/conformite" class="grad-text underline">Conformité</a>).',
},
{
'q': 'DictIA s\'intègre-t-il à Clio Manage ou PCLaw?',
'a': 'L\'intégration native Clio Manage est prévue pour Q1&nbsp;2026. En attendant, DictIA exporte nativement en DOCX, compatible avec tous les logiciels de gestion de dossiers. L\'importation manuelle prend moins de 30&nbsp;secondes par transcription. Intégrations natives disponibles&nbsp;: Word, Outlook, Teams, Notion, Obsidian, Zapier, Make, n8n.',
},
{
'q': 'Ai-je besoin de connaissances techniques?',
'a': 'Non. DictIA est une solution clé en main&nbsp;: nous fournissons le matériel (solutions locales), installons tout sur site, formons votre équipe et assurons la maintenance mensuelle à distance. Vous n\'avez besoin d\'aucune expertise technique. En cas de résiliation, vos données restent exportables pendant 90&nbsp;jours (art.&nbsp;23 LPRPSP).',
},
{
'q': 'DictIA est-il open source?',
'a': 'Oui. Le code source est sous licence AGPL&nbsp;v3 — transparence totale. La stack complète (WhisperX, pyannote, Mistral, Ollama, FastAPI, PostgreSQL) est 100&nbsp;% open source, sans aucune redevance logicielle. Code source complet sur <a href="https://gitea.innova-ai.ca/Innova-AI/dictia-public" target="_blank" rel="noopener" class="grad-text underline">Gitea public</a>. Conséquence pratique de l\'AGPL&nbsp;: tout fork hébergé doit publier ses modifications.',
},
]
@marketing_bp.route('/')
def landing():
"""Marketing landing page — public, indexable, French-Canadian.
Called directly (not via redirect) from src/api/recordings.py:index
when the visitor is anonymous. See B-1.3 fix commit af29539 for context.
"""
return render_template(
'marketing/landing.html',
testimonials=TESTIMONIALS,
faq=FAQ,
)
@marketing_bp.route('/tarifs')
def tarifs():
"""Standalone pricing page — same 3 forfaits as landing /#tarifs anchor,
plus deep-dive comparison matrix and tarification FAQ.
"""
return render_template('marketing/tarifs.html', faq=FAQ)
@marketing_bp.route('/fonctionnalites')
def fonctionnalites():
"""Standalone features page — deep-dive on the 6 bento features
plus full integrations list and supported export formats.
"""
return render_template('marketing/fonctionnalites.html')
@marketing_bp.route('/conformite')
def conformite():
"""Standalone compliance page — Loi 25, LGGRI, AGPL, EFVP details."""
return render_template('marketing/conformite.html')
@marketing_bp.route('/contact', methods=['GET'])
def contact():
"""Contact page — pre-launch: mailto-only form (no backend submit yet).
POST handler will be added in B-2.x once form-handling + Turnstile are wired.
"""
return render_template('marketing/contact.html')

View File

@@ -33,6 +33,9 @@ from .push_subscription import PushSubscription
from .processing_job import ProcessingJob from .processing_job import ProcessingJob
from .token_usage import TokenUsage from .token_usage import TokenUsage
from .transcription_usage import TranscriptionUsage from .transcription_usage import TranscriptionUsage
from .consent import ConsentLog
from .subscription import Subscription
from .webhook_event import WebhookEvent
# Export all models # Export all models
__all__ = [ __all__ = [
@@ -70,4 +73,8 @@ __all__ = [
'ProcessingJob', 'ProcessingJob',
'TokenUsage', 'TokenUsage',
'TranscriptionUsage', 'TranscriptionUsage',
'ConsentLog',
# Billing models (B-2.8)
'Subscription',
'WebhookEvent',
] ]

65
src/models/consent.py Normal file
View File

@@ -0,0 +1,65 @@
"""ConsentLog model — Loi 25 audit trail.
Records every grant/revoke of user consent for: CGU, confidentiality (RPRP),
marketing communications, analytics. Required by LPRPSP art. 14 (consent
explicit and tracé) and art. 3.5 (audit trail).
"""
from datetime import datetime
from sqlalchemy.orm import validates
from src.database import db
class ConsentLog(db.Model):
"""Journal Loi 25 — traçabilité des consentements utilisateurs.
One row per (user, consent_type, version) state change. Granting,
revoking, and re-granting all create separate rows for the audit trail.
"""
__tablename__ = 'consent_log'
ALLOWED_CONSENT_TYPES = ('cgu', 'confidentialite', 'marketing', 'analytics')
id = db.Column(db.Integer, primary_key=True)
# nullable + ondelete=SET NULL preserves audit trail (LPRPSP art. 3.5) while
# supporting right-to-erasure (LPRPSP art. 28.1): on user deletion, the row
# survives with user_id=NULL — proof that consent existed without identifying
# the data subject. Pattern matches src/models/auth_log.py and access_log.py.
user_id = db.Column(
db.Integer,
db.ForeignKey('user.id', ondelete='SET NULL'),
nullable=True,
index=True,
)
# 'cgu', 'confidentialite', 'marketing', 'analytics'
consent_type = db.Column(db.String(50), nullable=False)
# Version of the legal text accepted. Convention: ISO date 'YYYY-MM-DD' of
# the document revision (e.g. '2026-04-27'). B-2.9 will define the canonical
# version constants in src/legal/__init__.py — DO NOT hardcode dates here.
version = db.Column(db.String(20), nullable=False)
granted = db.Column(db.Boolean, nullable=False)
granted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
revoked_at = db.Column(db.DateTime, nullable=True)
# Source IP — supports both IPv4 (15 chars) and IPv6 (45 chars)
ip_address = db.Column(db.String(45), nullable=False)
user_agent = db.Column(db.String(500), nullable=False)
# Backref creates User.consent_logs
user = db.relationship('User', backref='consent_logs')
@validates('consent_type')
def _validate_consent_type(self, key, value):
if value not in self.ALLOWED_CONSENT_TYPES:
raise ValueError(
f"Invalid consent_type {value!r}. "
f"Must be one of: {self.ALLOWED_CONSENT_TYPES}"
)
return value
def __repr__(self):
action = 'granted' if self.granted else 'revoked'
return f"<ConsentLog user={self.user_id} type={self.consent_type} v={self.version} {action}>"

View File

@@ -0,0 +1,55 @@
"""DictIA subscription model — Stripe subscription state mirror (B-2.8).
This table is updated EXCLUSIVELY by the Stripe webhook handler. Never
write to it from user-facing routes (Checkout creates the Stripe
subscription; webhook reflects its state into our DB).
Each row corresponds to one Stripe Subscription object. A user can have
multiple historical subscriptions (renewed, cancelled, re-subscribed).
"""
from datetime import datetime
from src.database import db
class Subscription(db.Model):
"""One row per Stripe Subscription. The active row for a user is the
one with status in ('active', 'trialing', 'past_due') ordered by
created_at DESC."""
__tablename__ = 'subscription'
id = db.Column(db.Integer, primary_key=True)
# Use ondelete=SET NULL so we keep historical billing records even if
# the user deletes their account (Loi 25 art. 28.1 right-to-erasure +
# accounting/tax retention obligations are reconciled by anonymizing
# rather than dropping the row).
user_id = db.Column(
db.Integer,
db.ForeignKey('user.id', ondelete='SET NULL'),
nullable=True,
index=True,
)
stripe_customer_id = db.Column(db.String(120), nullable=False, index=True)
# Stripe subscription ID is unique — UNIQUE constraint also gives natural
# dedup against duplicate webhook deliveries of checkout.session.completed
stripe_subscription_id = db.Column(db.String(120), unique=True, nullable=False)
plan_slug = db.Column(db.String(40), nullable=False)
period = db.Column(db.String(10), nullable=False) # 'monthly' | 'yearly'
# Stripe subscription status: 'trialing' | 'active' | 'past_due' |
# 'canceled' | 'incomplete' | 'incomplete_expired' | 'unpaid' | 'paused'
status = db.Column(db.String(20), nullable=False, index=True)
# Period end: when next invoice will be billed (or when access expires
# if status='canceled' with cancel_at_period_end=True)
current_period_end = db.Column(db.DateTime, nullable=True)
# When the subscription was first created in Stripe
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Last time we received a webhook event updating this subscription
updated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship('User', backref='subscriptions')
def __repr__(self):
return f'<Subscription {self.stripe_subscription_id} {self.status} {self.plan_slug}/{self.period}>'

View File

@@ -9,9 +9,17 @@ from datetime import datetime
from flask_login import UserMixin from flask_login import UserMixin
from src.database import db from src.database import db
# ConsentLog backref defined in src/models/consent.py — accessible as User.consent_logs
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
"""User model for authentication and profile management.""" """User model authentication, profile, MFA enrollment, and subscription state.
Post-B-2.1 columns include MFA (totp_secret_encrypted, totp_enabled,
webauthn_credentials), Stripe billing (stripe_customer_id, subscription_status),
and ordre professionnel context (ordre_pro, cabinet) used at signup (B-2.2).
Consent audit trail in src/models/consent.py via User.consent_logs backref.
"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False) username = db.Column(db.String(20), unique=True, nullable=False)
@@ -62,6 +70,29 @@ class User(db.Model, UserMixin):
transcription_hotwords = db.Column(db.Text, nullable=True) transcription_hotwords = db.Column(db.Text, nullable=True)
transcription_initial_prompt = db.Column(db.Text, nullable=True) transcription_initial_prompt = db.Column(db.Text, nullable=True)
# === B-2.1: MFA / WebAuthn / Stripe / Loi 25 fields (Phase 2 backend) ===
# B-2.5 service layer encrypts the base32 secret with SECRET_KEY before storing.
# The encrypted blob (Fernet token) is what lives in this column. NEVER assign a
# raw base32 secret to this attribute — use the service-layer setter.
totp_secret_encrypted = db.Column(db.String(255), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
# WebAuthn / Passkey credentials (B-2.6) — list of credential dicts:
# [{'id': str, 'public_key': str, 'sign_count': int, 'transports': list[str]}]
webauthn_credentials = db.Column(db.JSON, nullable=True)
# B-2.5: 10 single-use recovery codes (bcrypt-hashed). Cleared when MFA disabled.
totp_recovery_codes = db.Column(db.JSON, nullable=True)
# Loi 25 + ordre professionnel context (used at signup B-2.2)
ordre_pro = db.Column(db.String(50), nullable=True) # 'barreau', 'cpa', 'chad', etc.
cabinet = db.Column(db.String(255), nullable=True)
# Stripe billing (B-2.7 / B-2.8)
stripe_customer_id = db.Column(db.String(120), nullable=True, index=True)
# 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | None
subscription_status = db.Column(db.String(20), nullable=True, index=True)
def __repr__(self): def __repr__(self):
return f"User('{self.username}', '{self.email}')" return f"User('{self.username}', '{self.email}')"

View File

@@ -0,0 +1,28 @@
"""Stripe webhook event ledger (B-2.8) — for idempotent processing.
Stripe delivers webhook events at least once. We record the event ID on
first successful processing; subsequent deliveries with the same ID are
no-op'd. Records are NOT garbage-collected automatically — operations
team can prune events older than 30 days if storage becomes a concern
(Stripe also has a 30-day delivery retry policy).
"""
from datetime import datetime
from src.database import db
class WebhookEvent(db.Model):
"""One row per processed Stripe webhook event."""
__tablename__ = 'webhook_event'
id = db.Column(db.Integer, primary_key=True)
# Stripe event ID (`evt_xxx`) — primary dedup key
stripe_event_id = db.Column(db.String(80), unique=True, nullable=False, index=True)
event_type = db.Column(db.String(80), nullable=False, index=True)
processed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Optional: store the related stripe_subscription_id or stripe_customer_id
# for fast lookup during incident debugging. Both nullable.
stripe_subscription_id = db.Column(db.String(120), nullable=True, index=True)
stripe_customer_id = db.Column(db.String(120), nullable=True, index=True)
def __repr__(self):
return f'<WebhookEvent {self.stripe_event_id} {self.event_type}>'

View File

@@ -11,6 +11,7 @@ import logging
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html import escape as html_escape
from typing import Optional from typing import Optional
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
@@ -24,7 +25,12 @@ PASSWORD_RESET_EXPIRY = 1 * 60 * 60 # 1 hour in seconds
def get_email_config(): def get_email_config():
"""Get email configuration from environment variables.""" """Get email configuration from environment variables.
Defaults are tuned for DictIA + Resend SMTP. Operators MUST set
``SMTP_FROM_ADDRESS`` to a domain verified in their Resend dashboard
(e.g. ``noreply@dictia.ca``).
"""
return { return {
'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true', 'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true',
'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true', 'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true',
@@ -35,7 +41,7 @@ def get_email_config():
'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true', 'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true',
'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true', 'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true',
'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'), 'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'),
'from_name': os.environ.get('SMTP_FROM_NAME', 'Speakr'), 'from_name': os.environ.get('SMTP_FROM_NAME', 'DictIA'),
} }
@@ -128,13 +134,13 @@ def _send_email(to_email: str, subject: str, html_body: str, text_body: str = No
msg['From'] = f"{config['from_name']} <{config['from_address']}>" msg['From'] = f"{config['from_name']} <{config['from_address']}>"
msg['To'] = to_email msg['To'] = to_email
# Add plain text version # Add plain text version (explicit UTF-8 to prevent Q-encoding mojibake)
if text_body: if text_body:
part1 = MIMEText(text_body, 'plain') part1 = MIMEText(text_body, 'plain', 'utf-8')
msg.attach(part1) msg.attach(part1)
# Add HTML version # Add HTML version (explicit UTF-8 to prevent Q-encoding mojibake)
part2 = MIMEText(html_body, 'html') part2 = MIMEText(html_body, 'html', 'utf-8')
msg.attach(part2) msg.attach(part2)
# Connect to SMTP server # Connect to SMTP server
@@ -165,32 +171,47 @@ def _send_email(to_email: str, subject: str, html_body: str, text_body: str = No
def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]: def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]:
""" """
Wrap content in the Speakr email template. Wrap content in the DictIA branded email template.
Header uses the DictIA brand gradient (118deg, #2563eb → #06b6d4 → #c026d3)
with a #2563eb fallback for clients that don't render gradients in inline
styles. The gradient matches the official DictIA logo (blue → cyan → fuchsia).
Footer mentions ``info@dictia.ca`` (canonical contact) and the
Loi 25 tagline.
Returns (html_body, text_body) Returns (html_body, text_body)
""" """
# Get the base URL for the logo # Get the base URL for the logo. We prefer the dedicated DictIA logo
# (logo-dictia.png) over the legacy PWA icon.
try: try:
logo_url = url_for('static', filename='img/icon-192x192.png', _external=True) logo_url = url_for('static', filename='img/logo-dictia.png', _external=True)
except RuntimeError: except RuntimeError:
# Outside of request context, use a placeholder # Outside of request context, use a placeholder
logo_url = "" logo_url = ""
# Header: solid #2563eb fallback + linear-gradient overlay (best-effort
# for the email clients that support inline-style gradients — Apple Mail,
# iOS Mail, Gmail web). Matches official DictIA logo (blue → cyan → fuchsia).
header_bg = (
"background-color: #2563eb; "
"background-image: linear-gradient(118deg, #2563eb 0%, #06b6d4 52%, #c026d3 100%);"
)
html_body = f""" html_body = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr-CA">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1f2937; margin: 0; padding: 0; background-color: #e8eaed;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #060d1a; margin: 0; padding: 0; background-color: #f7f9fc;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #e8eaed;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f7f9fc;">
<tr> <tr>
<td style="padding: 40px 20px;"> <td style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;">
<!-- Header --> <!-- Header -->
<tr> <tr>
<td style="background-color: #2563eb; padding: 32px 40px; border-radius: 12px 12px 0 0;"> <td style="{header_bg} padding: 32px 40px; border-radius: 12px 12px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td> <td>
@@ -198,10 +219,10 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
<table role="presentation" cellspacing="0" cellpadding="0" border="0"> <table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr> <tr>
<td style="vertical-align: middle; padding-right: 12px;"> <td style="vertical-align: middle; padding-right: 12px;">
<img src="{logo_url}" alt="Speakr" width="44" height="44" style="display: block; border-radius: 8px;"> <img src="{logo_url}" alt="DictIA" width="44" height="44" style="display: block; border-radius: 8px;">
</td> </td>
<td style="vertical-align: middle;"> <td style="vertical-align: middle;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">Speakr</h1> <h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">DictIA</h1>
</td> </td>
</tr> </tr>
</table> </table>
@@ -209,7 +230,7 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
</tr> </tr>
<tr> <tr>
<td style="padding-top: 8px;"> <td style="padding-top: 8px;">
<p style="color: rgba(255,255,255,0.85); margin: 0; font-size: 14px;">AI-Powered Audio Transcription</p> <p style="color: rgba(255,255,255,0.92); margin: 0; font-size: 14px;">Transcription IA conforme Loi&nbsp;25</p>
</td> </td>
</tr> </tr>
</table> </table>
@@ -218,22 +239,22 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
<!-- Content --> <!-- Content -->
<tr> <tr>
<td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;"> <td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e6ebf2; border-right: 1px solid #e6ebf2;">
{content_html} {content_html}
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background-color: #f8f9fa; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e5e7eb; border-top: none;"> <td style="background-color: #f7f9fc; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e6ebf2; border-top: none;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center;"> <td style="text-align: center;">
<p style="color: #6b7280; font-size: 12px; margin: 0 0 8px 0;"> <p style="color: #4b5563; font-size: 12px; margin: 0 0 8px 0;">
This email was sent by Speakr. If you have questions, please contact your administrator. Ce courriel vous est envoyé par DictIA. Pour toute question, contactez <a href="mailto:info@dictia.ca" style="color: #2563eb; text-decoration: none;">info@dictia.ca</a>.
</p> </p>
<p style="color: #9ca3af; font-size: 11px; margin: 0;"> <p style="color: #6b7280; font-size: 11px; margin: 0;">
&copy; {datetime.utcnow().year} Speakr &middot; AI-Powered Audio Transcription &copy; {datetime.utcnow().year} DictIA &mdash; Transcription IA conforme Loi&nbsp;25
</p> </p>
</td> </td>
</tr> </tr>
@@ -255,8 +276,8 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
{content_text} {content_text}
--- ---
This email was sent by Speakr - AI-Powered Audio Transcription. Ce courriel vous est envoyé par DictIA — Transcription IA conforme Loi 25.
If you have questions, please contact your administrator. Pour toute question, contactez info@dictia.ca.
""" """
return html_body, text_body return html_body, text_body
@@ -290,41 +311,49 @@ def send_verification_email(user) -> bool:
# Build verification URL # Build verification URL
verify_url = url_for('auth.verify_email', token=token, _external=True) verify_url = url_for('auth.verify_email', token=token, _external=True)
subject = "Verify your email address - Speakr" # Display name preferred over username; fallback chain handles None/empty
# name AND the schema-improbable case where username is also missing.
# HTML body MUST escape user-controlled name to prevent stored XSS;
# text body uses raw string (plaintext has no XSS surface).
raw_display_name = ((getattr(user, 'name', None) or '').strip() or user.username or 'utilisateur').strip()
display_name_html = html_escape(raw_display_name)
display_name_text = raw_display_name
subject = "Vérifiez votre courriel — DictIA"
content_html = f""" content_html = f"""
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Verify Your Email Address</h2> <h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Vérifiez votre adresse courriel</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p> <p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;"> <p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
</p> </p>
<div style="text-align: center; margin: 32px 0;"> <div style="text-align: center; margin: 32px 0;">
<a href="{verify_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Verify Email Address</a> <a href="{verify_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Vérifier mon courriel</a>
</div> </div>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p> <p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{verify_url}</p> <p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{verify_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;"> <div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #9ca3af; font-size: 13px; margin: 0;"> <p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>This link will expire in 24 hours.</strong><br> <strong>Ce lien expire dans 24&nbsp;heures.</strong><br>
If you didn't create an account on Speakr, you can safely ignore this email. Si vous n'avez pas créé de compte DictIA, ignorez ce courriel.
</p> </p>
</div> </div>
""" """
content_text = f"""Hi {user.username}, content_text = f"""Bonjour {display_name_text},
Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
Click here to verify: {verify_url} Cliquez ici pour vérifier : {verify_url}
This link will expire in 24 hours. Ce lien expire dans 24 heures.
If you didn't create an account on Speakr, you can safely ignore this email.""" Si vous n'avez pas créé de compte DictIA, ignorez ce courriel."""
html_body, text_body = _get_email_template(content_html, content_text, subject) html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body) return _send_email(user.email, subject, html_body, text_body)
@@ -354,50 +383,118 @@ def send_password_reset_email(user) -> bool:
# Build reset URL # Build reset URL
reset_url = url_for('auth.reset_password', token=token, _external=True) reset_url = url_for('auth.reset_password', token=token, _external=True)
subject = "Reset your password - Speakr" # Display name preferred over username; fallback chain handles None/empty
# name AND the schema-improbable case where username is also missing.
# HTML body MUST escape user-controlled name to prevent stored XSS;
# text body uses raw string (plaintext has no XSS surface).
raw_display_name = ((getattr(user, 'name', None) or '').strip() or user.username or 'utilisateur').strip()
display_name_html = html_escape(raw_display_name)
display_name_text = raw_display_name
subject = "Réinitialiser votre mot de passe — DictIA"
content_html = f""" content_html = f"""
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Reset Your Password</h2> <h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Réinitialiser votre mot de passe</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p> <p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;"> <p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
We received a request to reset your Speakr account password. Click the button below to create a new password. Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe.
</p> </p>
<div style="text-align: center; margin: 32px 0;"> <div style="text-align: center; margin: 32px 0;">
<a href="{reset_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Reset Password</a> <a href="{reset_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Réinitialiser mon mot de passe</a>
</div> </div>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p> <p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{reset_url}</p> <p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{reset_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;"> <div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="color: #4b5563; font-size: 13px; margin: 0;">
<tr> <strong>Ce lien expire dans 1&nbsp;heure.</strong><br>
<td style="width: 24px; vertical-align: top; padding-right: 12px;"> Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé.
<span style="font-size: 18px;">⚠️</span> </p>
</td>
<td>
<p style="color: #9ca3af; font-size: 13px; margin: 0;">
<strong style="color: #6b7280;">This link will expire in 1 hour.</strong><br>
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
</p>
</td>
</tr>
</table>
</div> </div>
""" """
content_text = f"""Hi {user.username}, content_text = f"""Bonjour {display_name_text},
We received a request to reset your Speakr account password. Click the link below to create a new password: Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :
{reset_url} {reset_url}
This link will expire in 1 hour. Ce lien expire dans 1 heure.
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.""" Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé."""
html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body)
def send_magic_link_email(user, magic_url: str) -> bool:
"""Send a magic-link login email (B-2.4).
Args:
user: User model instance (must have .email; .name preferred for display).
magic_url: Absolute URL to the magic-link consume endpoint.
The token itself is generated by ``src.auth.magic_link.generate_magic_link_token``
and embedded in ``magic_url`` by the caller — this function only renders
+ sends the email. Stateless tokens (no DB column).
Returns True if the email was sent successfully, False otherwise.
"""
if not is_smtp_configured():
logger.warning("Cannot send magic-link email: SMTP not configured")
return False
# Display name preferred over username; fallback chain handles None/empty
# name AND the schema-improbable case where username is also missing.
# HTML body MUST escape user-controlled name to prevent stored XSS;
# text body uses raw string (plaintext has no XSS surface).
raw_display_name = (
(getattr(user, 'name', None) or '').strip()
or user.username
or 'utilisateur'
).strip()
display_name_html = html_escape(raw_display_name)
display_name_text = raw_display_name
subject = "Votre lien de connexion DictIA"
content_html = f"""
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Votre lien de connexion</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
Cliquez sur le bouton ci-dessous pour vous connecter à DictIA sans mot de passe. Ce lien est à usage personnel et expire rapidement.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="{magic_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Se connecter à DictIA</a>
</div>
<p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{magic_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>Ce lien expire dans 15&nbsp;minutes.</strong><br>
Si vous n'avez pas demandé ce lien de connexion, ignorez ce courriel — votre compte reste sécurisé.
</p>
</div>
"""
content_text = f"""Bonjour {display_name_text},
Cliquez sur le lien ci-dessous pour vous connecter à DictIA sans mot de passe :
{magic_url}
Ce lien expire dans 15 minutes.
Si vous n'avez pas demandé ce lien de connexion, ignorez ce courriel — votre compte reste sécurisé."""
html_body, text_body = _get_email_template(content_html, content_text, subject) html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body) return _send_email(user.email, subject, html_body, text_body)

46
static/css/input.css Normal file
View File

@@ -0,0 +1,46 @@
@import "tailwindcss";
@config "./tailwind.config.js";
@layer base {
@font-face {
font-family: 'Inter Variable';
src: url('/static/fonts/Inter-Variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Variable';
src: url('/static/fonts/JetBrainsMono-Variable.woff2') format('woff2-variations');
font-weight: 100 800;
font-display: swap;
}
body {
@apply font-sans bg-white text-brand-navy antialiased;
}
h1, h2, h3 { @apply font-black; letter-spacing: -0.022em; }
h1 { letter-spacing: -0.028em; }
}
@layer utilities {
.grad-text {
@apply bg-brand-grad bg-clip-text text-transparent;
}
.grad-bg {
@apply bg-brand-grad text-white;
}
.eyebrow {
@apply text-[11px] uppercase font-bold tracking-[0.18em];
}
}
/* WCAG 2.3.3 — respect user's motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

6546
static/css/marketing.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './templates/login.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'],
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
b1: '#2563eb',
b2: '#06b6d4',
b3: '#c026d3',
navy: '#060d1a',
navy2: '#0b1525',
navy3: '#0f1e35',
bg: '#f7f9fc',
border: '#e6ebf2',
},
},
fontFamily: {
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono Variable', 'JetBrains Mono', 'monospace'],
},
backgroundImage: {
'brand-grad': 'linear-gradient(118deg, #2563eb, #06b6d4 52%, #c026d3)',
},
boxShadow: {
'cta': '0 4px 20px rgba(37, 99, 235, 0.28)',
'cta-hover': '0 8px 32px rgba(37, 99, 235, 0.42)',
},
borderRadius: {
DEFAULT: '0.75rem',
},
keyframes: {
'tc-fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(16px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'tc-fade-in-right': {
'0%': { opacity: '0', transform: 'translateX(-16px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'tc-float-y': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-8px)' },
},
'tc-pulse-glow': {
'0%, 100%': { boxShadow: '0 4px 20px rgba(37, 99, 235, 0.28)' },
'50%': { boxShadow: '0 8px 32px rgba(37, 99, 235, 0.42)' },
},
'plus-breathe': {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
},
animation: {
'tc-fade-in-up': 'tc-fade-in-up 600ms ease-out forwards',
'tc-fade-in-right': 'tc-fade-in-right 600ms ease-out forwards',
'tc-float-y': 'tc-float-y 4s ease-in-out infinite',
'tc-pulse-glow': 'tc-pulse-glow 3s ease-in-out infinite',
'plus-breathe': 'plus-breathe 2s ease-in-out infinite',
},
},
},
plugins: [],
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 258 KiB

11
static/images/favicon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0062ff"/>
<stop offset="52%" stop-color="#00bdd8"/>
<stop offset="100%" stop-color="#00c896"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<text x="32" y="46" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-weight="900" font-size="40" fill="#fff">D</text>
</svg>

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 28 KiB

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
// ROI calculator for DictIA pricing section (v7.0).
// Hypotheses transparentes (cf. footnote dans landing.html) :
// - 80% du temps de transcription manuelle est économisé
// - 220 jours ouvrables/an
// - Coût annuel comparé = Cloud ESSENTIEL = 349 $ × 12 = 4 188 $
window.roiCalculator = function roiCalculator() {
return {
users: 5,
hours: 2,
rate: 200,
get savings() {
const hoursSaved = this.users * this.hours * 0.8 * 220;
return Math.round(hoursSaved * this.rate);
},
get payback() {
// Cloud ESSENTIEL annual cost (no setup fee)
const annualCost = 349 * 12;
if (this.savings <= 0) return null;
return (annualCost / this.savings) * 12;
}
};
};

View File

@@ -0,0 +1,178 @@
/* DictIA WebAuthn client (B-2.6).
* No external dependencies. Wraps the navigator.credentials API and
* exchanges base64url-encoded payloads with the Flask backend at
* /2fa/passkey/* endpoints.
*
* Exports window.DictIAWebAuthn = { wireRegisterButton, wireAuthButton }.
*/
(function (global) {
'use strict';
// --- base64url helpers (no padding) -----------------------------------
function b64urlToBuffer(s) {
if (!s) return new ArrayBuffer(0);
const pad = '='.repeat((4 - (s.length % 4)) % 4);
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToB64url(buf) {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// --- Options decoding (server sends b64url; navigator.credentials needs ArrayBuffer)
function decodeRegistrationOptions(o) {
return Object.assign({}, o, {
challenge: b64urlToBuffer(o.challenge),
user: Object.assign({}, o.user, { id: b64urlToBuffer(o.user.id) }),
excludeCredentials: (o.excludeCredentials || []).map(function (c) {
return Object.assign({}, c, { id: b64urlToBuffer(c.id) });
}),
});
}
function decodeAuthenticationOptions(o) {
return Object.assign({}, o, {
challenge: b64urlToBuffer(o.challenge),
allowCredentials: (o.allowCredentials || []).map(function (c) {
return Object.assign({}, c, { id: b64urlToBuffer(c.id) });
}),
});
}
// --- Credential encoding (ArrayBuffer fields → b64url for JSON) -------
function encodeRegistrationCredential(cred) {
return {
id: cred.id,
rawId: bufferToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufferToB64url(cred.response.clientDataJSON),
attestationObject: bufferToB64url(cred.response.attestationObject),
transports: typeof cred.response.getTransports === 'function'
? cred.response.getTransports() : [],
},
clientExtensionResults: cred.getClientExtensionResults
? cred.getClientExtensionResults() : {},
};
}
function encodeAuthenticationAssertion(cred) {
return {
id: cred.id,
rawId: bufferToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufferToB64url(cred.response.clientDataJSON),
authenticatorData: bufferToB64url(cred.response.authenticatorData),
signature: bufferToB64url(cred.response.signature),
userHandle: cred.response.userHandle
? bufferToB64url(cred.response.userHandle) : null,
},
clientExtensionResults: cred.getClientExtensionResults
? cred.getClientExtensionResults() : {},
};
}
// --- HTTP helper ------------------------------------------------------
async function postJson(url, body, csrfToken) {
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const r = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body || {}),
credentials: 'same-origin',
});
let data = null;
try { data = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, data: data };
}
// --- Public API: wire enrolment button --------------------------------
function wireRegisterButton(cfg) {
const btn = document.getElementById(cfg.buttonId);
const labelEl = cfg.labelInputId ? document.getElementById(cfg.labelInputId) : null;
const statusEl = cfg.statusElementId ? document.getElementById(cfg.statusElementId) : null;
if (!btn) return;
btn.addEventListener('click', async function () {
if (statusEl) statusEl.textContent = 'Préparation...';
btn.disabled = true;
try {
if (!('credentials' in navigator) || !navigator.credentials.create) {
throw new Error('Votre navigateur ne supporte pas les passkeys.');
}
const beginRes = await postJson(cfg.beginUrl, {}, cfg.csrfToken);
if (!beginRes.ok) {
throw new Error((beginRes.data && beginRes.data.message) || 'Erreur de préparation');
}
const opts = decodeRegistrationOptions(beginRes.data);
if (statusEl) statusEl.textContent = 'Suivez les instructions de votre authentificateur...';
const cred = await navigator.credentials.create({ publicKey: opts });
const encoded = encodeRegistrationCredential(cred);
const finishRes = await postJson(
cfg.finishUrl,
{ response: encoded, label: labelEl ? labelEl.value : '' },
cfg.csrfToken
);
if (!finishRes.ok) {
throw new Error((finishRes.data && finishRes.data.message) || 'Échec de la vérification');
}
if (statusEl) statusEl.textContent = 'Passkey enregistrée. Rafraîchissement...';
setTimeout(function () { window.location.reload(); }, 800);
} catch (e) {
if (statusEl) statusEl.textContent = 'Erreur : ' + (e.message || e);
btn.disabled = false;
}
});
}
// --- Public API: wire login button ------------------------------------
function wireAuthButton(cfg) {
const btn = document.getElementById(cfg.buttonId);
const statusEl = cfg.statusElementId ? document.getElementById(cfg.statusElementId) : null;
if (!btn) return;
btn.addEventListener('click', async function () {
if (statusEl) statusEl.textContent = 'Préparation...';
btn.disabled = true;
try {
if (!('credentials' in navigator) || !navigator.credentials.get) {
throw new Error('Votre navigateur ne supporte pas les passkeys.');
}
const beginRes = await postJson(cfg.beginUrl, {}, cfg.csrfToken);
if (!beginRes.ok) {
throw new Error((beginRes.data && beginRes.data.message) || 'Erreur de préparation');
}
const opts = decodeAuthenticationOptions(beginRes.data);
if (statusEl) statusEl.textContent = 'Confirmez avec votre authentificateur...';
const cred = await navigator.credentials.get({ publicKey: opts });
const encoded = encodeAuthenticationAssertion(cred);
const finishRes = await postJson(cfg.finishUrl, { response: encoded }, cfg.csrfToken);
if (!finishRes.ok) {
throw new Error((finishRes.data && finishRes.data.message) || 'Échec de la vérification');
}
window.location.assign((finishRes.data && finishRes.data.redirect) || '/');
} catch (e) {
if (statusEl) statusEl.textContent = 'Erreur : ' + (e.message || e);
btn.disabled = false;
}
});
}
global.DictIAWebAuthn = {
wireRegisterButton: wireRegisterButton,
wireAuthButton: wireAuthButton,
};
})(window);

View File

@@ -1,65 +1,71 @@
# DictIA - Block all web crawlers and search engines # DictIA - robots.txt
# This application contains private user data and should not be indexed # Updated 2026-04-27 for marketing redesign (Task B-1.3)
#
# Public marketing pages (root, /tarifs, /fonctionnalites, /conformite,
# /contact, /blog) and legal pages (/legal/*) are indexable.
# Application routes (/api, /admin, /account, /share, /app, /checkout,
# /login, /signup, /webhooks) remain blocked.
User-agent: * User-agent: *
Disallow: / Allow: /
Allow: /tarifs
Allow: /fonctionnalites
Allow: /conformite
Allow: /contact
Allow: /blog/
Allow: /legal/
Disallow: /api/
Disallow: /admin
Disallow: /account
Disallow: /share/
Disallow: /app/
Disallow: /checkout
Disallow: /login
Disallow: /signup
Disallow: /oublie
Disallow: /verifier-email
Disallow: /webhooks/
# Specific directives for major search engines # Google-Extended (Bard/Gemini training): explicit opt-in to public marketing
User-agent: Googlebot User-agent: Google-Extended
Disallow: / Allow: /
Allow: /tarifs
User-agent: Googlebot-Image Allow: /fonctionnalites
Disallow: / Allow: /conformite
Allow: /contact
User-agent: Bingbot Allow: /blog/
Disallow: / Allow: /legal/
Disallow: /api/
User-agent: Slurp Disallow: /admin
Disallow: / Disallow: /account
Disallow: /share/
User-agent: DuckDuckBot Disallow: /app/
Disallow: / Disallow: /checkout
Disallow: /login
User-agent: Baiduspider Disallow: /signup
Disallow: / Disallow: /oublie
Disallow: /verifier-email
User-agent: YandexBot Disallow: /webhooks/
Disallow: /
User-agent: ia_archiver
Disallow: /
# AI Crawlers
User-agent: GPTBot
Disallow: /
# ChatGPT-User (on-demand browsing): explicit opt-in to public marketing
User-agent: ChatGPT-User User-agent: ChatGPT-User
Disallow: / Allow: /
Allow: /tarifs
Allow: /fonctionnalites
Allow: /conformite
Allow: /contact
Allow: /blog/
Allow: /legal/
Disallow: /api/
Disallow: /admin
Disallow: /account
Disallow: /share/
Disallow: /app/
Disallow: /checkout
Disallow: /login
Disallow: /signup
Disallow: /oublie
Disallow: /verifier-email
Disallow: /webhooks/
User-agent: CCBot Sitemap: https://dictia.pages.dev/sitemap.xml
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: cohere-ai
Disallow: /
# Social Media Crawlers
User-agent: facebookexternalhit
Disallow: /
User-agent: Twitterbot
Disallow: /
User-agent: LinkedInBot
Disallow: /
User-agent: Slackbot
Disallow: /
User-agent: Discordbot
Disallow: /

View File

@@ -1,127 +1,64 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% include 'includes/loading_overlay.html' %} {% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% elif action == 'magic_link' %}Lien de connexion envoyé — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="check-email-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta text-center">
const savedMode = localStorage.getItem('darkMode'); <div class="mx-auto mb-6 w-16 h-16 rounded-full grad-bg flex items-center justify-center text-white text-2xl" aria-hidden="true">&#x2709;</div>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> <h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> {% if action == 'password_reset' %}Vérifiez votre courriel
{% with messages = get_flashed_messages(with_categories=true) %} {% elif action == 'verification_required' %}Vérification requise
{% if messages %} {% elif action == 'magic_link' %}Lien de connexion envoyé
{% for category, message in messages %} {% else %}Confirmez votre courriel{% endif %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}"> </h1>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="text-center"> <p class="text-sm text-brand-navy/70 mb-6">
<div class="mb-6"> {% if action == 'password_reset' %}
<div class="w-20 h-20 mx-auto bg-[var(--bg-info-light)] rounded-full flex items-center justify-center"> Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1&#160;heure.
<i class="fas fa-envelope text-[var(--text-info-strong)] text-3xl"></i> {% elif action == 'verification_required' %}
</div> Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous.
</div> {% elif action == 'magic_link' %}
Si un compte vérifié existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien de connexion. Le lien expire dans {{ "15&nbsp;minutes" | safe }}.
{% else %}
Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24&#160;heures.
{% endif %}
</p>
{% if action == 'verification' %} {% with messages = get_flashed_messages(with_categories=true) %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2> {% if messages %}
<p class="text-[var(--text-secondary)] mb-2">We've sent a verification link to:</p> {% for category, message in messages %}
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p> <div role="alert" class="mb-3 p-3 rounded text-sm
<p class="text-[var(--text-muted)] text-sm mb-6"> {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
Click the link in the email to verify your account. The link will expire in 24 hours. {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
</p> {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% elif action == 'verification_required' %} {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verification Required</h2> {{ message }}
<p class="text-[var(--text-secondary)] mb-2">Please verify your email address:</p> </div>
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p> {% endfor %}
<p class="text-[var(--text-muted)] text-sm mb-6"> {% endif %}
Check your inbox for a verification email. If you haven't received it, you can request a new one. {% endwith %}
</p>
{% elif action == 'password_reset' %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
<p class="text-[var(--text-secondary)] mb-2">If an account exists with this email:</p>
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
<p class="text-[var(--text-muted)] text-sm mb-6">
We've sent a password reset link. The link will expire in 1 hour.
</p>
{% endif %}
{% if show_resend and (action == 'verification' or action == 'verification_required') %} {% if show_resend and action != 'password_reset' %}
<div class="mb-6"> <form method="POST" action="{{ url_for('auth.resend_verification') }}" class="mb-4">
<form method="POST" action="{{ url_for('auth.resend_verification') }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="email" value="{{ email }}">
<input type="hidden" name="email" value="{{ email }}"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<button type="submit" class="text-[var(--text-accent)] hover:underline text-sm"> Renvoyer le lien de vérification
<i class="fas fa-redo mr-1"></i> Resend verification email </button>
</button> </form>
</form> {% endif %}
</div>
{% endif %}
<div class="pt-4 border-t border-[var(--border-secondary)]"> <p class="text-xs text-brand-navy/70 mt-4">
<a href="{{ url_for('auth.login') }}" class="text-[var(--text-accent)] hover:underline"> Vous ne recevez rien&nbsp;? Vérifiez vos pourriels (spam) ou
<i class="fas fa-arrow-left mr-1"></i> Back to Login <a href="mailto:info@dictia.ca" class="grad-text font-semibold">contactez le support</a>.
</a> </p>
</div>
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="mt-6 text-sm">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
</footer> </p>
</div> </div>
</section>
<script> {% endblock %}
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -1,105 +1,46 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% include 'includes/loading_overlay.html' %} {% block title %}Mot de passe oublié — DictIA{% endblock %}
{% block description %}Recevez un lien sécurisé pour réinitialiser le mot de passe de votre compte DictIA.{% endblock %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="forgot-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
const savedMode = localStorage.getItem('darkMode'); <h1 id="forgot-title" class="text-3xl font-black text-brand-navy mb-2">Mot de passe oublié</h1>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; <p class="text-sm text-brand-navy/70 mb-6">{{ "Entrez votre adresse courriel. Si un compte existe, nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe (valide 1&nbsp;heure)." | safe }}</p>
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> {% with messages = get_flashed_messages(with_categories=true) %}
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> {% if messages %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Forgot Password</h2> {% for category, message in messages %}
<p class="text-[var(--text-muted)] text-sm text-center mb-6"> <div role="alert" class="mb-3 p-3 rounded text-sm
Enter your email address and we'll send you a link to reset your password. {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
</p> {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages(with_categories=true) %} <form method="POST" action="{{ url_for('auth.forgot_password') }}" class="space-y-4" novalidate>
{% if messages %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.forgot_password') }}"> <div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" autocomplete="email" required aria-required="true"
class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="vous@cabinet.qc.ca">
</div>
<div class="mb-6"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Email Address</label> Recevoir un lien de réinitialisation
<input type="email" id="email" name="email" required </button>
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" </form>
placeholder="Enter your email address">
</div>
<div class="flex flex-col space-y-4"> <p class="text-center text-sm text-brand-navy/70 mt-6">
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]"> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
<i class="fas fa-paper-plane mr-2"></i> Send Reset Link </p>
</button> </div>
</section>
<div class="text-center text-sm text-[var(--text-muted)]"> {% endblock %}
<span>Remember your password?</span>
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Back to Login</a>
</div>
</div>
</form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
{% extends 'marketing/base.html' %}
{% block title %}Lien de connexion DictIA{% endblock %}
{% block description %}Recevez un lien magique pour vous connecter à DictIA sans mot de passe.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="magic-title">
<div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
<h1 id="magic-title" class="text-3xl font-black text-brand-navy mb-2">Lien de connexion</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "Recevez un lien par courriel pour vous connecter sans mot de passe. Le lien expire dans 15&nbsp;minutes." | safe }}</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.magic_link_request') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" autocomplete="email" required aria-required="true"
class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="vous@cabinet.qc.ca">
</div>
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
{{ "Recevoir le lien (expire dans 15&nbsp;minutes)" | safe }}
</button>
</form>
<p class="text-xs text-brand-navy/70 mt-4">
Pour des raisons de sécurité, le lien n'est envoyé qu'aux comptes dont le courriel est vérifié. Si vous ne recevez rien, vérifiez vos pourriels (spam).
</p>
<p class="text-center text-sm text-brand-navy/70 mt-6">
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold hover:underline">&larr; Retour à la connexion</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends 'marketing/base.html' %}
{% block title %}Finaliser votre inscription DictIA{% endblock %}
{% block description %}Finalisez votre inscription DictIA — consentements Loi&nbsp;25 requis pour créer votre compte.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="finish-title">
<div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
<h1 id="finish-title" class="text-3xl font-black text-brand-navy mb-2">Finaliser votre inscription</h1>
<p class="text-sm text-brand-navy/70 mb-6">
Vous vous inscrivez via <strong>{{ provider_display or provider | capitalize }}</strong>. Avant de créer votre compte DictIA, nous devons obtenir vos consentements conformément à la {{ "Loi&nbsp;25" | safe }} du Québec.
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# Pre-filled email from OAuth provider — display only, not editable #}
<div class="bg-brand-bg border border-brand-border rounded-none p-3 mb-6 text-sm">
<p class="text-brand-navy/70 mb-1">Compte fédéré :</p>
<p class="text-brand-navy font-semibold break-all">{{ userinfo.email }}</p>
{% if userinfo.name %}<p class="text-brand-navy/80 text-xs mt-1">{{ userinfo.name }}</p>{% endif %}
</div>
<form method="POST" action="{{ url_for('auth.oauth_finish_signup') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
<fieldset class="space-y-3 pt-2">
<legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi&nbsp;25" | safe }}</legend>
<label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_cgu" name="consent_cgu" value="y" required aria-required="true"
class="mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label>
{% if errors.consent_cgu %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_cgu }}</p>{% endif %}
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_confidentialite" name="consent_confidentialite" value="y" required aria-required="true"
class="mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label>
{% if errors.consent_confidentialite %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_confidentialite }}</p>{% endif %}
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_marketing" name="consent_marketing" value="y"
class="mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span>J'accepte de recevoir des communications marketing (optionnel, désactivable à tout moment).</span>
</label>
<label for="consent_analytics" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_analytics" name="consent_analytics" value="y"
class="mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span>J'accepte les statistiques d'usage anonymisées (optionnel, désactivable à tout moment).</span>
</label>
</fieldset>
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Créer mon compte DictIA
</button>
</form>
<p class="text-center text-sm text-brand-navy/70 mt-6">
Vous voulez utiliser un autre courriel&nbsp;?
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Inscription manuelle</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends 'marketing/base.html' %}
{% block title %}Gérer mes passkeys — DictIA{% endblock %}
{% block description %}Gérez les passkeys de votre compte DictIA — second facteur sans mot de passe (FIDO2 / biométrie).{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="passkey-setup-title">
<div class="max-w-2xl mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
<h1 id="passkey-setup-title" class="text-3xl font-black text-brand-navy mb-2">Mes passkeys</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "Une passkey est un second facteur sans mot de passe (clé matérielle YubiKey, biométrie de votre appareil, etc.). Conforme&nbsp;Loi&nbsp;25." | safe }}</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<h2 class="text-base font-semibold text-brand-navy mb-3">Passkeys enregistrées</h2>
{% if credentials %}
<ul class="space-y-2 mb-6" role="list">
{% for cred in credentials %}
<li class="flex items-center justify-between p-3 border border-brand-border rounded">
<div>
<p class="font-medium text-brand-navy">{{ cred.name }}</p>
<p class="text-xs text-brand-navy/70">Ajoutée le {{ cred.created_at[:10] }}</p>
</div>
<form method="POST" action="{{ url_for('auth.passkey_delete', credential_id=cred.id) }}" onsubmit="return confirm('Supprimer cette passkey ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-sm text-red-700 hover:text-red-900 font-medium focus-visible:outline-2 focus-visible:outline-red-700 focus-visible:outline-offset-2">
Supprimer
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-brand-navy/70 mb-6">Aucune passkey enregistrée pour le moment.</p>
{% endif %}
<h2 class="text-base font-semibold text-brand-navy mb-3">Ajouter une passkey</h2>
<div class="space-y-3">
<label for="passkey-label" class="block text-sm font-medium text-brand-navy">Nom de la passkey (optionnel)</label>
<input id="passkey-label" type="text" maxlength="80" placeholder="ex. YubiKey 5C, MacBook Touch ID..." class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<button id="passkey-register-btn" type="button" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Enregistrer une passkey
</button>
<p id="passkey-register-status" class="text-xs text-brand-navy/70" role="status" aria-live="polite"></p>
</div>
<p class="text-center text-sm mt-6 pt-4 border-t border-brand-border">
<a href="{{ url_for('auth.account') }}" class="grad-text font-semibold">&larr; Retour à mon compte</a>
</p>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/webauthn-client.js') }}"></script>
<script>
if (window.DictIAWebAuthn) {
window.DictIAWebAuthn.wireRegisterButton({
buttonId: 'passkey-register-btn',
labelInputId: 'passkey-label',
statusElementId: 'passkey-register-status',
beginUrl: '{{ url_for("auth.passkey_register_begin") }}',
finishUrl: '{{ url_for("auth.passkey_register_finish") }}',
csrfToken: '{{ csrf_token() }}',
});
}
</script>
{% endblock %}

View File

@@ -1,114 +1,54 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% include 'includes/loading_overlay.html' %} {% block title %}Nouveau mot de passe — DictIA{% endblock %}
{% block description %}Définissez un nouveau mot de passe pour votre compte DictIA. Lien sécurisé valide 1 heure.{% endblock %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="reset-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
const savedMode = localStorage.getItem('darkMode'); <h1 id="reset-title" class="text-3xl font-black text-brand-navy mb-2">Nouveau mot de passe</h1>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; <p class="text-sm text-brand-navy/70 mb-6">Choisissez un mot de passe robuste pour sécuriser votre compte DictIA.</p>
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> {% with messages = get_flashed_messages(with_categories=true) %}
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> {% if messages %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Reset Password</h2> {% for category, message in messages %}
<p class="text-[var(--text-muted)] text-sm text-center mb-6"> <div role="alert" class="mb-3 p-3 rounded text-sm
Enter your new password below. {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
</p> {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages(with_categories=true) %} <form method="POST" action="{{ url_for('auth.reset_password', token=token) }}" class="space-y-4" novalidate>
{% if messages %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}"> <div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Nouveau mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required aria-required="true" aria-describedby="password-help"
class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="••••••••">
<p id="password-help" class="text-xs text-brand-navy/70 mt-1">{{ "8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial." | safe }}</p>
</div>
<div class="mb-4"> <div>
<label for="password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">New Password</label> <label for="confirm_password" class="block text-sm font-medium text-brand-navy mb-1">Confirmer le mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="password" id="password" name="password" required <input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" required aria-required="true"
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="Enter your new password"> placeholder="••••••••">
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p> </div>
</div>
<div class="mb-6"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Confirm Password</label> Définir mon nouveau mot de passe
<input type="password" id="confirm_password" name="confirm_password" required </button>
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" </form>
placeholder="Confirm your new password">
</div>
<div class="flex flex-col space-y-4"> <p class="text-center text-sm text-brand-navy/70 mt-6">
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]"> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
<i class="fas fa-key mr-2"></i> Reset Password </p>
</button> </div>
</section>
<div class="text-center text-sm text-[var(--text-muted)]"> {% endblock %}
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Back to Login
</a>
</div>
</div>
</form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
{% extends 'marketing/base.html' %}
{% block title %}Configurer la double authentification — DictIA{% endblock %}
{% block description %}Activez la double authentification (TOTP) sur votre compte DictIA pour protéger vos données conformément aux exigences Loi&nbsp;25.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="totp-setup-title">
<div class="max-w-2xl mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
<h1 id="totp-setup-title" class="text-3xl font-black text-brand-navy mb-2">Configurer la double authentification</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "La double authentification (2FA) ajoute une seconde étape lors de la connexion, en plus de votre mot de passe. Une exigence forte recommandée pour les comptes traitant des données confidentielles (Loi&nbsp;25)." | safe }}</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<div role="alert" class="mb-4 p-3 rounded text-sm bg-red-50 text-red-900 border border-red-200">{{ error }}</div>
{% endif %}
<ol class="space-y-6">
<li>
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">1.</span> Installez une application d'authentification</h2>
<p class="text-sm text-brand-navy/80">Sur votre téléphone, installez par exemple <strong>Google Authenticator</strong>, <strong>Microsoft Authenticator</strong>, <strong>Authy</strong> ou <strong>1Password</strong>.</p>
</li>
<li>
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">2.</span> Scannez le code QR</h2>
<div class="flex flex-col md:flex-row gap-6 items-start">
<div class="bg-brand-bg border border-brand-border rounded p-4 flex-shrink-0">
<img src="{{ qr_data_url }}" alt="Code QR pour configurer DictIA dans votre application authenticator" class="w-48 h-48 mx-auto block">
</div>
<div class="text-sm text-brand-navy/80 space-y-2">
<p>Pointez l'appareil photo de votre application authenticator vers ce code QR.</p>
<p class="text-xs text-brand-navy/60">Vous ne pouvez pas scanner&nbsp;?<br>Saisissez la clé manuellement&nbsp;:</p>
<code class="block bg-brand-bg border border-brand-border rounded-none px-3 py-2 text-xs font-mono text-brand-navy break-all select-all">{{ secret }}</code>
</div>
</div>
</li>
<li>
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">3.</span> Conservez vos codes de récupération</h2>
<div role="alert" class="bg-amber-50 border border-amber-200 rounded p-4 mb-3">
<p class="text-sm font-semibold text-amber-900 mb-2">Important — ces codes ne seront affichés qu'une seule fois.</p>
<p class="text-xs text-amber-900/90">Imprimez-les ou enregistrez-les dans votre gestionnaire de mots de passe. Chaque code est à usage unique et permettra de vous reconnecter si vous perdez l'accès à votre application authenticator.</p>
</div>
<pre id="recovery-codes" class="bg-brand-navy text-white text-sm font-mono p-4 rounded-none whitespace-pre-wrap select-all">{% for c in recovery_codes %}{{ c }}
{% endfor %}</pre>
<button type="button" onclick="(function(){var t=document.getElementById('recovery-codes').innerText;if(navigator.clipboard){navigator.clipboard.writeText(t);}var b=document.getElementById('copy-btn');b.textContent='Copié dans le presse-papiers';setTimeout(function(){b.textContent='Copier les codes';},2000);})();"
id="copy-btn"
class="mt-2 inline-flex items-center gap-2 text-xs font-semibold text-brand-b1 hover:underline focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Copier les codes
</button>
</li>
<li>
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">4.</span> Confirmez avec un code à 6 chiffres</h2>
<p class="text-sm text-brand-navy/70 mb-4">Entrez le code à 6 chiffres affiché actuellement dans votre application authenticator pour valider l'installation.</p>
<form method="POST" action="{{ url_for('auth.totp_setup') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="code" class="block text-sm font-medium text-brand-navy mb-1">Code à 6 chiffres <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="text" id="code" name="code" required aria-required="true"
inputmode="numeric" autocomplete="one-time-code"
pattern="[0-9]{6}" maxlength="6"
class="w-full md:w-48 px-3 py-2 border border-brand-border rounded-none text-brand-navy text-center text-xl font-mono tracking-widest focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="000000" autofocus>
</div>
<button type="submit" class="grad-bg text-white font-semibold py-3 px-6 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Activer la double authentification
</button>
</form>
</li>
</ol>
<p class="text-center text-sm text-brand-navy/70 mt-8 pt-6 border-t border-brand-border">
<a href="{{ url_for('auth.account') }}" class="grad-text font-semibold">&larr; Annuler et retourner au compte</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends 'marketing/base.html' %}
{% block title %}Vérification 2FA — DictIA{% endblock %}
{% block description %}Saisissez votre code à 6 chiffres pour terminer la connexion à votre compte DictIA.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="totp-verify-title">
<div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
<h1 id="totp-verify-title" class="text-3xl font-black text-brand-navy mb-2">Vérification en deux étapes</h1>
<p class="text-sm text-brand-navy/70 mb-6">Entrez le code à 6 chiffres affiché dans votre application authenticator pour terminer la connexion.</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<div role="alert" class="mb-4 p-3 rounded text-sm bg-red-50 text-red-900 border border-red-200">{{ error }}</div>
{% endif %}
{# B-2.6: Passkey path (only if user has at least one registered passkey) #}
{% if has_passkeys %}
<section class="mb-6" aria-labelledby="passkey-section-title">
<h2 id="passkey-section-title" class="text-base font-semibold text-brand-navy mb-3">Connexion par Passkey</h2>
<button id="passkey-auth-btn" type="button" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Utiliser ma Passkey
</button>
<p id="passkey-status" class="text-xs text-brand-navy/70 mt-2" role="status" aria-live="polite"></p>
</section>
{% if has_totp %}
<div class="my-4 flex items-center gap-3 text-xs uppercase tracking-wider text-brand-navy/50" aria-hidden="true">
<span class="flex-1 h-px bg-brand-border"></span><span>ou</span><span class="flex-1 h-px bg-brand-border"></span>
</div>
{% endif %}
{% endif %}
{% if has_totp %}
{# Primary path: 6-digit TOTP code #}
<form method="POST" action="{{ url_for('auth.totp_verify_login') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="code" class="block text-sm font-medium text-brand-navy mb-1">Code à 6 chiffres <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="text" id="code" name="code"
inputmode="numeric" autocomplete="one-time-code"
pattern="[0-9]{6}" maxlength="6"
class="w-full px-3 py-3 border border-brand-border rounded-none text-brand-navy text-center text-2xl font-mono tracking-widest focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="000000" autofocus>
</div>
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Vérifier et se connecter
</button>
</form>
{# Secondary path: recovery code (collapsed by default for clarity) #}
<details class="mt-6 border-t border-brand-border pt-4">
<summary class="cursor-pointer text-sm font-semibold text-brand-navy hover:text-brand-b1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Pas accès à votre application authenticator&nbsp;? Utiliser un code de récupération
</summary>
<form method="POST" action="{{ url_for('auth.totp_verify_login') }}" class="space-y-4 mt-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="recovery_code" class="block text-sm font-medium text-brand-navy mb-1">Code de récupération <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="text" id="recovery_code" name="recovery_code"
autocomplete="off"
class="w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy font-mono uppercase focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="XXXXX-XXXXX">
<p class="text-xs text-brand-navy/60 mt-1">Format&nbsp;: 5 caractères + tiret + 5 caractères. Chaque code est à usage unique.</p>
</div>
<button type="submit" class="w-full bg-brand-navy text-white font-semibold py-3 rounded-none hover:bg-brand-navy2 transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Utiliser le code de récupération
</button>
<p class="text-xs text-brand-navy/60 text-center" aria-live="polite">{{ recovery_codes_remaining }} code{{ 's' if recovery_codes_remaining != 1 else '' }} de récupération restant{{ 's' if recovery_codes_remaining != 1 else '' }}.</p>
</form>
</details>
{% endif %}
<p class="text-center text-sm text-brand-navy/70 mt-6 pt-4 border-t border-brand-border">
<a href="{{ url_for('auth.logout') }}" class="grad-text font-semibold">Annuler la connexion</a>
</p>
</div>
</section>
{% endblock %}
{% block scripts %}
{% if has_passkeys %}
<script src="{{ url_for('static', filename='js/webauthn-client.js') }}"></script>
<script>
if (window.DictIAWebAuthn) {
window.DictIAWebAuthn.wireAuthButton({
buttonId: 'passkey-auth-btn',
statusElementId: 'passkey-status',
beginUrl: '{{ url_for("auth.passkey_auth_begin") }}',
finishUrl: '{{ url_for("auth.passkey_auth_finish") }}',
csrfToken: '{{ csrf_token() }}',
});
}
</script>
{% endif %}
{% endblock %}

View File

@@ -1,85 +1,27 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% include 'includes/loading_overlay.html' %} {% block title %}Courriel vérifié — DictIA{% endblock %}
{% block description %}Votre courriel a été vérifié. Vous pouvez maintenant vous connecter à votre compte DictIA.{% endblock %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="verify-success-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta text-center">
const savedMode = localStorage.getItem('darkMode'); <div class="mx-auto mb-6 w-16 h-16 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-3xl font-black" aria-hidden="true">&check;</div>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> <h1 id="verify-success-title" class="text-2xl font-black text-brand-navy mb-2">Votre courriel a été vérifié</h1>
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <p class="text-sm text-brand-navy/70 mb-6">
<div class="text-center"> Vous pouvez maintenant vous connecter à votre compte DictIA et commencer à transcrire en toute conformité Loi&nbsp;25.
<div class="mb-6"> </p>
<div class="w-20 h-20 mx-auto bg-[var(--bg-success-light)] rounded-full flex items-center justify-center">
<i class="fas fa-check text-[var(--text-success-strong)] text-3xl"></i>
</div>
</div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verified!</h2> <a href="{{ url_for('auth.login') }}"
<p class="text-[var(--text-secondary)] mb-6"> class="inline-block w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Your email address has been successfully verified. You can now log in to your account. Se connecter
</p> </a>
<a href="{{ url_for('auth.login') }}" class="inline-block w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200"> <p class="text-xs text-brand-navy/70 mt-6">
<i class="fas fa-sign-in-alt mr-2"></i> Continue to Login Une question&nbsp;? Écrivez-nous à
</a> <a href="mailto:info@dictia.ca" class="grad-text font-semibold">info@dictia.ca</a>.
</div> </p>
</div> </div>
</main> </section>
{% endblock %}
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
{% extends 'marketing/base.html' %}
{% block title %}{{ title or 'Paiement annulé — DictIA' }}{% endblock %}
{% block description %}Paiement annulé. Aucun montant n'a été prélevé. Vous pouvez reprendre votre inscription à tout moment.{% endblock %}
{% block content %}
{# ===== HERO ===== #}
<section class="bg-brand-navy text-white py-20" aria-labelledby="page-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<div class="w-20 h-20 bg-white/[0.06] border border-white/[0.12] rounded-full mx-auto mb-6 flex items-center justify-center text-white/80" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-9 h-9"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</div>
<p class="eyebrow grad-text mb-4">PAIEMENT ANNULÉ</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Aucun problème — <span class="grad-text">aucun montant prélevé</span>.
</h1>
<p class="text-lg text-white/80">
Vous avez fermé la page de paiement avant de finaliser. Aucune carte n'a été débitée. Vous pouvez reprendre votre inscription à tout moment.
</p>
</div>
</section>
{# ===== INFO + NEXT STEPS ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="info-title">
<div class="max-w-[820px] mx-auto px-6">
<h2 id="info-title" class="sr-only">Que faire ensuite</h2>
<div class="bg-white p-8 rounded border border-brand-border mb-8">
<h3 class="text-lg font-bold mb-3 text-brand-navy">Pourquoi avoir hésité&nbsp;?</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed mb-4">
Si vous avez une question sur les forfaits, la conformité Loi&nbsp;25 ou la mise en service, notre équipe peut vous accompagner sans pression commerciale.
</p>
<p class="text-sm text-brand-navy/80 leading-relaxed">
Écrivez-nous à <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a> ou appelez le <a href="tel:+15819968471" class="grad-text font-semibold hover:underline">(581)&nbsp;996-8471</a>. Réponse sous 2&nbsp;jours ouvrables.
</p>
</div>
<div class="text-center">
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% from 'macros/button.html' import button %}
{{ button('Revoir les tarifs', href='/tarifs', variant='primary', size='lg') }}
{{ button('Retour à l\'accueil', href='/', variant='ghost', size='lg') }}
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends 'marketing/base.html' %}
{% block title %}{{ title or 'Paiement confirmé — DictIA' }}{% endblock %}
{% block description %}Paiement confirmé. Votre abonnement DictIA sera activé sous quelques minutes. Vous recevrez un courriel de confirmation.{% endblock %}
{% block content %}
{# ===== HERO ===== #}
<section class="bg-brand-navy text-white py-20" aria-labelledby="page-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<div class="w-20 h-20 grad-bg rounded-full mx-auto mb-6 flex items-center justify-center text-white shadow-cta" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10"><path d="M5 13l4 4L19 7"/></svg>
</div>
<p class="eyebrow grad-text mb-4">PAIEMENT CONFIRMÉ</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Merci&nbsp;! Votre <span class="grad-text">paiement est confirmé</span>.
</h1>
<p class="text-lg text-white/80">
Votre abonnement sera activé sous quelques minutes. Vous recevrez un courriel de confirmation à l'adresse associée à votre compte.
</p>
</div>
</section>
{# ===== NEXT STEPS ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="next-steps-title">
<div class="max-w-[820px] mx-auto px-6">
<h2 id="next-steps-title" class="text-[clamp(1.75rem,2.5vw,2.25rem)] font-black mb-8 text-brand-navy text-center">
Prochaines étapes.
</h2>
<ol class="space-y-6">
<li class="bg-white p-6 rounded border border-brand-border flex gap-4">
<span class="grad-bg text-white font-black w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-cta" aria-hidden="true">1</span>
<div>
<h3 class="font-bold text-brand-navy mb-1">Confirmation par courriel</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed">
Vous recevrez un reçu détaillé (avec TPS et TVQ ventilées) dans les prochaines minutes. Vérifiez vos pourriels si rien n'arrive après 10&nbsp;minutes.
</p>
</div>
</li>
<li class="bg-white p-6 rounded border border-brand-border flex gap-4">
<span class="grad-bg text-white font-black w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-cta" aria-hidden="true">2</span>
<div>
<h3 class="font-bold text-brand-navy mb-1">Activation de votre abonnement</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed">
Votre statut d'abonnement sera mis à jour automatiquement dès que Stripe confirme la transaction (généralement sous 2&nbsp;minutes). Aucune action requise de votre part.
</p>
</div>
</li>
<li class="bg-white p-6 rounded border border-brand-border flex gap-4">
<span class="grad-bg text-white font-black w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-cta" aria-hidden="true">3</span>
<div>
<h3 class="font-bold text-brand-navy mb-1">Mise en service</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed">
Pour les forfaits <strong>DictIA&nbsp;Cloud</strong>&nbsp;: accès immédiat depuis votre tableau de bord.<br>
Pour les forfaits <strong>DictIA&nbsp;8</strong> et <strong>DictIA&nbsp;16</strong> (on-premise)&nbsp;: notre équipe vous contactera sous 1&nbsp;jour ouvrable pour planifier l'installation (~2&nbsp;semaines).
</p>
</div>
</li>
</ol>
{% if session_id %}
<p class="text-xs text-brand-navy/60 mt-8 text-center font-mono break-all">
Référence&nbsp;: {{ session_id }}
</p>
{% endif %}
</div>
</section>
{# ===== CTA ===== #}
<section class="bg-white py-16" aria-labelledby="cta-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<h2 id="cta-title" class="text-[clamp(1.5rem,2vw,2rem)] font-black mb-4 text-brand-navy">
Une question&nbsp;?
</h2>
<p class="text-base text-brand-navy/80 mb-6">
Notre équipe est joignable à <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a> ou au <a href="tel:+15819968471" class="grad-text font-semibold hover:underline">(581)&nbsp;996-8471</a>.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% from 'macros/button.html' import button %}
{{ button('Retour à l\'accueil', href='/', variant='ghost', size='lg') }}
{{ button('Voir les tarifs', href='/tarifs', variant='secondary', size='lg') }}
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,381 @@
{% extends 'marketing/base.html' %}
{% block title %}{{ title }} — DictIA{% endblock %}
{% block description %}{{ description }}{% endblock %}
{% block head_extra %}
<style>
/* ---------------------------------------------------------------------------
Typographie pour le markdown rendu (héritée de B-2.9, étendue B-2.10).
Inlinée pour ne pas avoir à reconstruire static/css/marketing.css.
--------------------------------------------------------------------------- */
.legal-content h2 {
position: relative;
font-size: 1.5rem; /* 24px */
line-height: 2rem;
font-weight: 700;
color: #060d1a; /* brand-navy */
margin-top: 2.75rem;
margin-bottom: 1rem;
letter-spacing: -0.022em;
scroll-margin-top: 90px; /* pour ancres sous le header sticky */
}
.legal-content h2::after {
content: '';
display: block;
width: 56px;
height: 4px;
margin-top: 0.5rem;
border-radius: 4px;
background: linear-gradient(118deg, #2563eb, #06b6d4 52%, #06b6d4);
}
.legal-content h3 {
font-size: 1.25rem; /* 20px */
line-height: 1.75rem;
font-weight: 600;
color: #060d1a;
margin-top: 2rem;
margin-bottom: 0.75rem;
scroll-margin-top: 90px;
}
.legal-content h4 {
font-size: 1.05rem;
font-weight: 600;
color: #060d1a;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.legal-content p {
margin-bottom: 1rem;
font-size: 1rem;
line-height: 1.75;
}
.legal-content ul,
.legal-content ol {
margin-bottom: 1rem;
margin-left: 1.5rem;
line-height: 1.75;
}
.legal-content ul { list-style-type: disc; list-style-position: outside; }
.legal-content ol { list-style-type: decimal; list-style-position: outside; }
.legal-content li { margin-bottom: 0.35rem; }
.legal-content a {
background: linear-gradient(118deg, #2563eb, #06b6d4 52%, #06b6d4);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 600;
text-decoration: underline;
text-decoration-color: #2563eb;
}
.legal-content a:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 2px;
}
.legal-content table {
width: 100%;
margin: 1rem 0 1.5rem;
border-collapse: collapse;
font-size: 0.875rem;
}
.legal-content th,
.legal-content td {
border: 1px solid #e6ebf2;
padding: 0.6rem 0.75rem;
text-align: left;
vertical-align: top;
}
.legal-content th {
background-color: #f7f9fc;
font-weight: 600;
color: #060d1a;
}
.legal-content tbody tr:nth-child(even) td {
background-color: #fafbfd;
}
.legal-content blockquote {
border-left: 4px solid #2563eb;
background-color: rgba(247, 249, 252, 0.6);
padding: 0.75rem 1rem;
margin: 1.25rem 0;
border-radius: 0;
font-style: italic;
color: rgba(6, 13, 26, 0.75);
}
.legal-content code {
padding: 0.15rem 0.4rem;
background-color: #f7f9fc;
border-radius: 0;
font-size: 0.875rem;
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', monospace;
}
.legal-content pre {
background-color: #f7f9fc;
border: 1px solid #e6ebf2;
border-radius: 0;
padding: 1rem;
overflow-x: auto;
margin-bottom: 1.25rem;
font-size: 0.875rem;
}
.legal-content pre code {
background-color: transparent;
padding: 0;
}
.legal-content hr {
margin: 2rem 0;
border: none;
border-top: 1px solid #e6ebf2;
}
.legal-content strong { font-weight: 700; color: #060d1a; }
/* DRAFT callout — visually distinct yellow banner */
.legal-content .draft-callout,
.legal-draft-callout {
background-color: #fffbeb;
border-left: 4px solid #f59e0b;
padding: 0.75rem 1rem;
margin: 1rem 0 1.5rem;
border-radius: 0;
font-size: 0.9rem;
color: #78350f;
}
/* ---------------------------------------------------------------------------
Sticky TOC + breadcrumb (desktop ≥ lg).
--------------------------------------------------------------------------- */
.legal-toc {
position: sticky;
top: 86px; /* sous header 62px + marge */
max-height: calc(100vh - 110px);
overflow-y: auto;
}
.legal-toc a {
border-left: 2px solid transparent;
transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
}
.legal-toc a:hover {
background-color: rgba(37,99,235, 0.05);
}
.legal-toc a.is-active {
border-left-color: #2563eb;
color: #2563eb !important;
background-color: rgba(37,99,235, 0.06);
}
.legal-breadcrumb {
position: sticky;
top: 62px;
z-index: 30;
background-color: rgba(247, 249, 252, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid #e6ebf2;
}
/* ---------------------------------------------------------------------------
Print stylesheet — hide nav chrome, keep article + header.
--------------------------------------------------------------------------- */
@media print {
header.fixed,
.legal-breadcrumb,
.legal-toc-wrapper,
.legal-prev-next,
footer {
display: none !important;
}
main { padding-top: 0 !important; }
.legal-content a {
color: #000 !important;
background: none !important;
-webkit-text-fill-color: #000 !important;
text-decoration: underline !important;
}
body { background: white !important; }
}
</style>
{% endblock %}
{% block content %}
{# Skip link (WCAG 2.4.1) — visible uniquement au focus clavier. #}
<a href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-brand-navy focus:text-white focus:rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Aller au contenu principal
</a>
{# Breadcrumb sticky #}
<nav class="legal-breadcrumb px-4 py-3" aria-label="Fil d'Ariane">
<ol class="max-w-[1200px] mx-auto flex flex-wrap items-center gap-2 text-xs md:text-sm text-brand-navy/70">
<li><a href="/" class="hover:text-brand-b1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 rounded-none">Accueil</a></li>
<li aria-hidden="true" class="text-brand-navy/40"></li>
<li><a href="{{ url_for('legal.legal_index') }}" class="hover:text-brand-b1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 rounded-none">Documents légaux</a></li>
<li aria-hidden="true" class="text-brand-navy/40"></li>
<li class="text-brand-navy font-semibold truncate" aria-current="page">{{ title }}</li>
</ol>
</nav>
<section class="bg-brand-bg pt-8 pb-16 px-4">
<div class="max-w-[1200px] mx-auto lg:grid lg:grid-cols-[1fr_240px] lg:gap-10">
{# Article principal #}
<article id="main-content"
role="main"
aria-labelledby="legal-title"
class="bg-white p-6 md:p-10 rounded border border-brand-border shadow-cta order-1">
<header class="mb-8 pb-6 border-b border-brand-border">
<p class="text-xs uppercase tracking-wider text-brand-navy/70 mb-2">Document légal DictIA</p>
<h1 id="legal-title" class="text-3xl md:text-4xl font-black text-brand-navy mb-4 tracking-tight">{{ title }}</h1>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-brand-navy/70">
<span class="inline-flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Version <strong class="text-brand-navy">{{ legal_version }}</strong></span>
</span>
<span class="text-brand-navy/40" aria-hidden="true">·</span>
<span>Dernière mise à jour&nbsp;: {{ legal_version }}</span>
<span class="text-brand-navy/40" aria-hidden="true">·</span>
<span>RPRP&nbsp;: <a href="mailto:rprp@dictia.ca" class="grad-text font-semibold underline">rprp@dictia.ca</a></span>
</div>
</header>
{# TOC mobile (collapsible) — visible < lg seulement #}
<details class="lg:hidden mb-6 border border-brand-border rounded bg-brand-bg/50">
<summary class="cursor-pointer px-4 py-3 text-sm font-semibold text-brand-navy flex items-center justify-between focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 rounded">
<span>Sur cette page</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</summary>
<ul id="legal-toc-mobile" class="px-4 pb-3 pt-1 space-y-1 text-sm">
{# Rempli côté JS (Alpine via init du desktop). #}
</ul>
</details>
<div class="legal-content text-brand-navy/90 leading-relaxed">
{{ content | safe }}
</div>
{# Prev / Next navigation #}
{% if prev_page or next_page %}
<nav class="legal-prev-next mt-12 pt-6 border-t border-brand-border grid sm:grid-cols-2 gap-3"
aria-label="Navigation entre documents légaux">
{% if prev_page %}
<a href="{{ url_for('legal.legal_page', page=prev_page) }}"
rel="prev"
class="block p-4 bg-brand-bg/60 border border-brand-border rounded hover:border-brand-b1 hover:bg-white transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span class="block text-xs uppercase tracking-wider text-brand-navy/60 mb-1 inline-flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
Précédent
</span>
<span class="block text-sm font-semibold text-brand-navy">{{ prev_title }}</span>
</a>
{% else %}
<span></span>
{% endif %}
{% if next_page %}
<a href="{{ url_for('legal.legal_page', page=next_page) }}"
rel="next"
class="block p-4 bg-brand-bg/60 border border-brand-border rounded hover:border-brand-b1 hover:bg-white transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 sm:text-right">
<span class="block text-xs uppercase tracking-wider text-brand-navy/60 mb-1 inline-flex items-center gap-1.5 sm:justify-end">
Suivant
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</span>
<span class="block text-sm font-semibold text-brand-navy">{{ next_title }}</span>
</a>
{% endif %}
</nav>
{% endif %}
<footer class="mt-8 pt-6 border-t border-brand-border text-sm text-brand-navy/70">
<p class="inline-flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
<a href="{{ url_for('legal.legal_index') }}" class="grad-text font-semibold">Index des documents légaux</a>
</p>
</footer>
</article>
{# TOC desktop — sidebar sticky #}
<aside class="legal-toc-wrapper hidden lg:block order-2"
aria-label="Table des matières">
<div x-data="legalToc()"
x-init="init()"
class="legal-toc bg-white border border-brand-border rounded p-5 mt-0">
<h2 class="text-xs font-bold uppercase tracking-wider text-brand-navy/70 mb-3">
Sur cette page
</h2>
<ul role="list">
<template x-for="item in items" :key="item.id">
<li>
<a :href="'#' + item.id"
:class="active === item.id ? 'is-active font-semibold' : ''"
:aria-current="active === item.id ? 'true' : null"
class="block py-1.5 pl-3 pr-2 text-sm text-brand-navy/70 hover:text-brand-b1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 rounded-none"
x-text="item.text"></a>
</li>
</template>
<template x-if="items.length === 0">
<li class="text-xs text-brand-navy/50 italic py-1">
Aucune section à afficher.
</li>
</template>
</ul>
</div>
</aside>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
// Construit la TOC en scannant les <h2> du contenu rendu, met l'élément actif
// à jour via IntersectionObserver. Synchronise aussi la liste mobile.
function legalToc() {
return {
items: [],
active: '',
init() {
const populate = () => {
const headings = Array.from(document.querySelectorAll('.legal-content h2'));
this.items = headings
.filter(h => h.id) // markdown.toc auto-id; skip if missing
.map(h => ({ id: h.id, text: h.textContent.trim() }));
// Mirror dans le <details> mobile.
const mobileList = document.getElementById('legal-toc-mobile');
if (mobileList) {
mobileList.innerHTML = '';
this.items.forEach(it => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#' + it.id;
a.textContent = it.text;
a.className = 'block py-1.5 text-brand-navy/80 hover:text-brand-b1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 rounded-none';
li.appendChild(a);
mobileList.appendChild(li);
});
}
if (headings.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) this.active = e.target.id;
});
}, { rootMargin: '-100px 0px -60% 0px' });
headings.forEach(el => observer.observe(el));
};
// Lance après que Alpine ait rendu et que le DOM soit posé.
if (document.readyState === 'complete') populate();
else window.addEventListener('load', populate, { once: true });
},
};
}
</script>
{% endblock %}

119
templates/legal/index.html Normal file
View File

@@ -0,0 +1,119 @@
{% extends 'marketing/base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block description %}{{ description }}{% endblock %}
{% block head_extra %}
<style>
/* Card hover/lift uniquement pour les cartes légales (sobre, accessible). */
.legal-card {
transition: transform 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
}
.legal-card:hover {
transform: translateY(-2px);
}
.legal-card:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 3px;
}
/* Icône circulaire avec dégradé de marque, contraste suffisant. */
.legal-card-icon {
background: linear-gradient(135deg, rgba(37,99,235,0.10), rgba(6,182,212,0.10));
color: #2563eb;
}
.legal-card.is-external .legal-card-icon {
background: linear-gradient(135deg, rgba(6,182,212,0.12), rgba(6,182,212,0.12));
}
/* Print : pas de bouton CTA, pas d'animations. */
@media print {
.legal-card { box-shadow: none !important; transform: none !important; }
}
</style>
{% endblock %}
{% block content %}
<a href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-brand-navy focus:text-white focus:rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Aller au contenu principal
</a>
<section class="bg-brand-bg pt-12 pb-20 px-4" aria-labelledby="legal-index-title">
<div id="main-content" class="max-w-[1100px] mx-auto">
{# Hero #}
<header class="text-center max-w-3xl mx-auto mb-12">
<p class="eyebrow text-brand-navy/60 mb-3">DictIA Inc.</p>
<h1 id="legal-index-title" class="text-4xl md:text-5xl font-black text-brand-navy mb-4 tracking-tight">
Documents <span class="grad-text">légaux</span> DictIA
</h1>
<p class="text-base md:text-lg text-brand-navy/70 leading-relaxed mb-5">
Transparence totale conforme à la {{ 'Loi&nbsp;25' | safe }} du Québec : tous nos documents juridiques,
notre code source et notre politique de confidentialité sont publics et indexables.
</p>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-brand-border text-xs font-semibold text-brand-navy/80">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>Version {{ legal_version }} · dernière mise à jour {{ legal_version }}</span>
</span>
</header>
{# Grille 6 cartes — 5 internes + 1 externe AGPL #}
<ul class="grid sm:grid-cols-2 gap-4 md:gap-5" role="list">
{% for page in pages %}
<li>
{% if page.external %}
<a href="{{ page.url }}"
target="_blank"
rel="noopener noreferrer"
class="legal-card is-external group block h-full p-5 md:p-6 bg-white border border-brand-border rounded hover:border-brand-b1 hover:shadow-cta focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 relative">
{% else %}
<a href="{{ url_for('legal.legal_page', page=page.slug) }}"
class="legal-card group block h-full p-5 md:p-6 bg-white border border-brand-border rounded hover:border-brand-b1 hover:shadow-cta focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2 relative">
{% endif %}
<div class="flex items-start gap-4">
<div class="legal-card-icon shrink-0 inline-flex items-center justify-center w-12 h-12 rounded-none">
{{ page.icon | safe }}
</div>
<div class="flex-1 min-w-0">
<h2 class="text-base md:text-lg font-bold text-brand-navy mb-1.5 group-hover:grad-text transition-colors">
{{ page.title }}{% if page.external %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block w-4 h-4 ml-1.5 text-brand-b1 align-text-top" aria-hidden="true"><path d="M7 17l9.2-9.2M17 17V8h-9"/></svg>{% endif %}
</h2>
<p class="text-sm text-brand-navy/70 leading-relaxed">{{ page.description }}</p>
{% if page.external %}
<p class="mt-2 inline-flex items-center gap-1.5 text-xs text-brand-navy/50 font-medium">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
<span>{{ page.url }}</span>
<span class="sr-only">(s'ouvre dans un nouvel onglet)</span>
</p>
{% endif %}
</div>
</div>
</a>
</li>
{% endfor %}
</ul>
{# Bloc info pied — signature, contact, sous-processeurs #}
<aside class="mt-12 max-w-3xl mx-auto bg-white border border-brand-border rounded p-6 md:p-7"
aria-label="Informations complémentaires sur les documents légaux">
<p class="text-sm text-brand-navy/80 leading-relaxed mb-3">
Documents signés numériquement par <strong class="text-brand-navy">Allison Rioux</strong>,
présidente et responsable de la protection des renseignements personnels (RPRP) —
version&nbsp;{{ legal_version }}.
</p>
<p class="text-sm text-brand-navy/80 leading-relaxed mb-3">
Pour toute question, demande d'accès, rectification ou plainte&nbsp;:
<a href="mailto:rprp@dictia.ca" class="grad-text font-semibold underline">rprp@dictia.ca</a>.
</p>
<p class="text-xs text-brand-navy/60 leading-relaxed">
<strong>5&nbsp;sous-processeurs</strong> : OVH (Beauharnois,&nbsp;QC) ·
Google Cloud (Toronto, Ontario) · Cloudflare (US) · HubSpot (US) · Stripe (US) —
détails dans la <a href="{{ url_for('legal.legal_page', page='confidentialite') }}" class="grad-text font-semibold">Politique de confidentialité</a>.
</p>
</aside>
</div>
</section>
{% endblock %}

View File

@@ -1,178 +1,120 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Loading overlay to prevent FOUC --> {% block title %}Connexion — DictIA{% endblock %}
{% include 'includes/loading_overlay.html' %} {% block description %}Connectez-vous à votre compte DictIA. Microsoft 365, Google, lien magique ou mot de passe.{% endblock %}
<script> {% block content %}
// Function to apply the theme based on localStorage <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="login-title">
function applyTheme() { <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
// Guard against early execution <h1 id="login-title" class="text-3xl font-black text-brand-navy mb-2">Connexion</h1>
if (!document.documentElement) return; <p class="text-sm text-brand-navy/70 mb-6">{{ "Bienvenue sur DictIA — la transcription IA conforme à la Loi&nbsp;25." | safe }}</p>
// Apply dark mode {% with messages = get_flashed_messages(with_categories=true) %}
const savedMode = localStorage.getItem('darkMode'); {% if messages %}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; {% for category, message in messages %}
if (savedMode === 'true' || (savedMode === null && prefersDark)) { <div role="alert" class="mb-3 p-3 rounded text-sm
document.documentElement.classList.add('dark'); {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
} else { {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
document.documentElement.classList.remove('dark'); {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
} {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
// Apply color scheme {# OAuth providers (Microsoft 365 + Google) — rendered only if env-enabled #}
const savedScheme = localStorage.getItem('colorScheme') || 'blue'; {% if oauth_microsoft_enabled or oauth_google_enabled or sso_enabled %}
const isDark = document.documentElement.classList.contains('dark'); <div class="space-y-3 mb-6" aria-label="Connexion fédérée">
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-'; {% if oauth_microsoft_enabled %}
<a href="{{ url_for('auth.oauth_provider_login', provider='microsoft') }}"
class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-none text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
{# Official Microsoft 4-square logo #}
<svg width="20" height="20" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
<rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
</svg>
<span>Continuer avec Microsoft 365</span>
</a>
{% endif %}
// Remove all other theme classes {% if oauth_google_enabled %}
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal']; <a href="{{ url_for('auth.oauth_provider_login', provider='google') }}"
themeClasses.forEach(theme => { class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-none text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
document.documentElement.classList.remove(`theme-light-${theme}`); {# Official Google "G" logo #}
document.documentElement.classList.remove(`theme-dark-${theme}`); <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
}); <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.56c2.08-1.92 3.28-4.74 3.28-8.09Z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.56-2.76c-.99.66-2.25 1.06-3.72 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"/>
<path fill="#FBBC05" d="M5.84 14.11A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.44.34-2.11V7.05H2.18a11 11 0 0 0 0 9.9l3.66-2.84Z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.07.56 4.21 1.64l3.16-3.16C17.46 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.05l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z"/>
</svg>
<span>Continuer avec Google</span>
</a>
{% endif %}
// Add the correct theme class {% if sso_enabled %}
if (savedScheme !== 'blue') { <a href="{{ url_for('auth.sso_login') }}"
document.documentElement.classList.add(themePrefix + savedScheme); class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-none text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
} <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
} <rect x="3" y="11" width="18" height="11" rx="2"/>
applyTheme(); <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</script> </svg>
</head> <span>Se connecter avec {{ sso_provider_name }}</span>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]"> </a>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen"> {% endif %}
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> {% if not password_login_disabled %}
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <div class="flex items-center text-xs uppercase tracking-wide text-brand-navy/60 my-3">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Connexion</h2> <span class="flex-grow border-t border-brand-border"></span>
<span class="mx-3">ou</span>
<span class="flex-grow border-t border-brand-border"></span>
</div>
{% endif %}
</div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %} {% if not password_login_disabled %}
{% if messages %} <form method="POST" action="{{ url_for('auth.login') }}" class="space-y-4" novalidate>
{% for category, message in messages %} {{ form.hidden_tag() }}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if sso_enabled %} <div>
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}"> <label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
<a href="{{ url_for('auth.sso_login') }}" class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200"> {{ form.email(id='email', type='email', autocomplete='email', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }} {% if form.email.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.email.errors[0] }}</p>{% endif %}
</a> </div>
{% if not password_login_disabled %}
<div class="flex items-center text-xs text-[var(--text-muted)]">
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
<span class="mx-3 uppercase tracking-wide">ou</span>
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
</div>
{% endif %}
</div>
{% endif %}
{% if password_login_disabled %} <div>
<div class="mt-4 text-center"> <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<button type="button" onclick="document.getElementById('admin-login-form').classList.toggle('hidden')" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]"> {{ form.password(id='password', autocomplete='current-password', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<i class="fas fa-lock mr-1"></i> Connexion administrateur {% if form.password.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.password.errors[0] }}</p>{% endif %}
</button> </div>
</div>
<form id="admin-login-form" method="POST" action="{{ url_for('auth.login') }}" class="hidden mt-4">
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Email administrateur") }}
</div>
<div class="mb-4">
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Mot de passe") }}
</div>
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
Se connecter
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-4"> <div class="flex items-center justify-between text-sm">
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} <label for="remember" class="flex items-center gap-2 text-brand-navy/90 cursor-pointer">
{% if form.email.errors %} {{ form.remember(id='remember', **{'class':'rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} <span>Se souvenir de moi</span>
<div class="text-[var(--text-danger)] text-xs mt-1"> </label>
{% for error in form.email.errors %} <a href="{{ url_for('auth.forgot_password') }}" class="grad-text font-semibold hover:underline">Mot de passe oublié&nbsp;?</a>
<span>{{ error }}</span> </div>
{% endfor %}
</div>
{% else %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="mb-4"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} Se connecter
{% if form.password.errors %} </button>
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} </form>
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="flex items-center justify-between mb-6"> <p class="text-center text-sm mt-4">
<div class="flex items-center"> <a href="{{ url_for('auth.magic_link_request') }}" class="grad-text font-semibold hover:underline">
{{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }} {{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }}
{{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }} </a>
</div> </p>
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-[var(--text-accent)] hover:underline">Mot de passe oublié ?</a> {% endif %}
</div>
<div class="flex flex-col space-y-4"> <p class="text-center text-sm text-brand-navy/70 mt-6">
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }} Pas encore de compte&nbsp;?
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Créer un compte</a>
<div class="text-center text-sm text-[var(--text-muted)]"> </p>
<span>Pas encore de compte ?</span> </div>
<a href="{{ url_for('auth.register') }}" class="font-medium text-[var(--text-accent)] hover:underline">S'inscrire</a> </section>
</div> {% endblock %}
</div>
</form>
{% endif %}
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<script>
// Hide loading overlay when page is ready
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{# Reusable bento card macro. FlexiHub style: dark navy2 surface, decorative watermark number, gradient icon corner.
`span` controls column span via a static lookup table (Tailwind's content scanner only sees literal class strings,
so dynamic `col-span-{{ span }}` would produce dead classes — the lookup keeps the utilities discoverable).
`icon` is rendered via `| safe` so callers can pass either inline SVG markup (preferred) or a plain string.
The default is a small inline sparkle SVG to avoid any emoji fallback. #}
{% macro bento_card(number, title, description, icon=None, span='1') %}
{%- set span_classes = {'1': 'col-span-1', '2': 'sm:col-span-2', '3': 'sm:col-span-2 md:col-span-3'} -%}
{%- set default_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-7 h-7" aria-hidden="true"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z"/></svg>' -%}
<div class="relative bg-brand-navy2 p-6 rounded overflow-hidden border border-white/[0.045] {{ span_classes.get(span, 'col-span-1') }}">
<div class="absolute top-2 right-4 text-[80px] font-black grad-text opacity-20 leading-none" aria-hidden="true">{{ number }}</div>
<div class="relative">
<div class="text-brand-b1 mb-4" aria-hidden="true">{{ (icon or default_icon) | safe }}</div>
<h3 class="text-lg font-bold mb-2 text-white">{{ title | safe }}</h3>
<p class="text-sm text-white/70">{{ description | safe }}</p>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,30 @@
{# Reusable button macro. Variants: primary | secondary | ghost (default: primary). Sizes: sm | md | lg (default: md). #}
{%- macro button(text, href='#', variant='primary', size='md', icon=None, target=None, rel=None, as_button=False, type='button') -%}
{%- set variants = {
'primary': 'grad-bg shadow-cta hover:shadow-cta-hover hover:-translate-y-px',
'secondary': 'bg-white text-brand-navy border border-brand-border hover:bg-brand-bg',
'ghost': 'text-white border border-white/[0.08] hover:bg-white/[0.05]'
} -%}
{%- set sizes = {
'sm': 'px-3 py-1.5 text-sm',
'md': 'px-5 py-2.5 text-[15px]',
'lg': 'px-6 py-3 text-base'
} -%}
{%- set classes = variants.get(variant, variants['primary']) -%}
{%- set sizing = sizes.get(size, sizes['md']) -%}
{%- if as_button -%}
<button type="{{ type }}"
class="inline-flex items-center justify-center gap-2 rounded-none font-semibold transition-all duration-200 {{ classes }} {{ sizing }}">
<span>{{ text }}</span>
{%- if icon -%}<span class="ml-0.5" aria-hidden="true">{{ icon | safe }}</span>{%- endif -%}
</button>
{%- else -%}
<a href="{{ href }}"
class="inline-flex items-center justify-center gap-2 rounded-none font-semibold transition-all duration-200 {{ classes }} {{ sizing }}"
{% if target %}target="{{ target }}"{% endif %}
{% if rel %}rel="{{ rel }}"{% endif %}>
<span>{{ text }}</span>
{%- if icon -%}<span class="ml-0.5" aria-hidden="true">{{ icon | safe }}</span>{%- endif -%}
</a>
{%- endif -%}
{%- endmacro -%}

View File

@@ -0,0 +1,116 @@
{# Reusable pricing card macro (v7.0). FlexiHub style — recommended tier gets a grad-bg outer border (1.5px gradient frame).
Args:
slug : URL-safe identifier (goes into href, NOT piped through | safe — autoescape protects URL)
name : Display name (piped through | safe — entity-free expected: "Cloud BASIC", "DictIA LOCAL"…)
target : Target audience tagline — piped through | safe (may contain entities)
features : List of feature strings, each piped through | safe (may contain &nbsp; entities)
badge : Top eyebrow chip text above the title — e.g. 'Cloud · Souverain QC' or 'Local · 100% hors-ligne'
recommended : If True, wraps the card in grad-bg gradient frame + RECOMMANDÉ badge
setup : One-shot setup price NUMBER (CAD, no NBSP) — None to hide. Cloud Basic/Essentiel = None,
Cloud Pro = 485, DictIA Local = 5998.
monthly : Monthly recurring price NUMBER (CAD, no NBSP) — None for DictIA Local (one-shot only).
yearly_renewal : Year-2+ renewal NUMBER (CAD, no NBSP) — only for DictIA Local (500$/an dès An 2).
capacity_audio : Capacity chip (audio hours / month) — e.g. '~165&nbsp;h audio/mois'
capacity_storage : Capacity chip (storage) — e.g. '100&nbsp;Go'
gpu : GPU chip — e.g. 'NVIDIA L4 partagé'
cta_label : Button text — e.g. 'Démarrer en Cloud', 'Configurer DictIA Local'
cta_url : Base URL for the CTA — slug appended (NOT piped through | safe — URL injection guard)
The numeric `setup` / `monthly` / `yearly_renewal` are formatted server-side
with French (fr-CA) thousands separator (NBSP) — `5998` → `5&nbsp;998&nbsp;$`.
This avoids requiring callers to remember OQLF NBSP conventions for every
price string.
Note: pre-launch hygiene (LPC art. 219) — CTA wording is supplied by the
caller (`cta_label`) so we no longer hardcode "Réserver" everywhere. #}
{# Format an integer like 5998 → '5&nbsp;998' (OQLF thousands separator) #}
{%- macro fmt_price(n) -%}
{%- set s = n | string -%}
{%- if s | length > 3 -%}{{ s[:-3] }}&nbsp;{{ s[-3:] }}{%- else -%}{{ s }}{%- endif -%}
{%- endmacro -%}
{%- macro pricing_card(slug, name, target, features,
badge=None, recommended=False,
setup=None, monthly=None, yearly_renewal=None,
capacity_audio=None, capacity_storage=None, gpu=None,
cta_label='Choisir ce forfait', cta_url='/checkout') -%}
<div class="relative {% if recommended %}grad-bg p-[1.5px] rounded shadow-cta{% endif %}">
{% if recommended %}<span class="absolute -top-3 left-1/2 -translate-x-1/2 grad-bg text-white text-xs font-bold px-3 py-1 rounded-full shadow-cta inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3" aria-hidden="true"><path d="M12 2l2.9 6.9L22 10l-5.5 4.8L18 22l-6-3.6L6 22l1.5-7.2L2 10l7.1-1.1z"/></svg>RECOMMANDÉ</span>{% endif %}
<div class="bg-white p-6 rounded border border-brand-border h-full flex flex-col">
{# Eyebrow badge (optional) — Cloud · Souverain QC / Local · 100% hors-ligne #}
{% if badge %}
<p class="eyebrow grad-text mb-3 text-[11px]">{{ badge | safe }}</p>
{% endif %}
{# Title + target audience #}
<div class="mb-5">
<h3 class="text-xl font-black mb-1.5 text-brand-navy">{{ name | safe }}</h3>
<p class="text-sm text-brand-navy/70 leading-snug">{{ target | safe }}</p>
</div>
{# Pricing block — 3 layouts:
- DictIA Local : setup (one-shot An 1) + yearly_renewal tagline
- Cloud Pro : setup (one-time onboarding) + monthly recurring
- Cloud Basic/Essentiel : monthly only #}
<div class="mb-5 pb-5 border-b border-brand-border">
{% if monthly is none and setup is not none %}
{# DictIA Local — one-shot An 1 + yearly renewal #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(setup) }}&nbsp;$</div>
<div class="text-xs text-brand-navy/70 mt-2">An&nbsp;1 (matériel + installation + 1<sup>re</sup> année logiciel)</div>
{% if yearly_renewal %}
<div class="text-xs text-brand-navy/70 mt-1">puis <strong class="text-brand-navy">{{ fmt_price(yearly_renewal) }}&nbsp;$/an</strong> dès An&nbsp;2</div>
{% endif %}
{% elif setup is not none and monthly is not none %}
{# Cloud Pro — setup + monthly #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }}&nbsp;$<span class="text-base text-brand-navy/60 font-bold">&nbsp;/&nbsp;mois</span></div>
<div class="text-xs text-brand-navy/70 mt-2">+ {{ fmt_price(setup) }}&nbsp;$ onboarding (unique)</div>
{% else %}
{# Cloud Basic / Essentiel — monthly only #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }}&nbsp;$<span class="text-base text-brand-navy/60 font-bold">&nbsp;/&nbsp;mois</span></div>
<div class="text-xs text-brand-navy/70 mt-2">Aucun frais d'installation</div>
{% endif %}
</div>
{# Capacity chips — audio / storage / GPU (only if provided) #}
{% if capacity_audio or capacity_storage or gpu %}
<div class="flex flex-wrap gap-1.5 mb-5" role="list" aria-label="Caractéristiques techniques">
{% if capacity_audio %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b1/[0.08] border border-brand-b1/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b1" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>{{ capacity_audio | safe }}</span>
</span>
{% endif %}
{% if capacity_storage %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b2/[0.08] border border-brand-b2/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b2" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>
<span>{{ capacity_storage | safe }}</span>
</span>
{% endif %}
{% if gpu %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b3/[0.08] border border-brand-b3/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b3" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 18v2"/><path d="M18 18v2"/><path d="M6 10h.01"/><path d="M10 10h4"/></svg>
<span>{{ gpu | safe }}</span>
</span>
{% endif %}
</div>
{% endif %}
{# Features list #}
<ul class="space-y-2.5 mb-6 flex-grow" role="list">
{% for f in features %}
<li class="flex items-start gap-2 text-sm text-brand-navy/80">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mt-0.5 flex-shrink-0 text-brand-b3" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
<span>{{ f | safe }}</span>
</li>
{% endfor %}
</ul>
{# CTA #}
{% from 'macros/button.html' import button %}
{{ button(cta_label, href=cta_url.rstrip('/') + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }}
</div>
</div>
{%- endmacro -%}

View File

@@ -0,0 +1,69 @@
<footer class="bg-brand-navy2 text-white py-16 mt-20" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">Navigation du pied de page</h2>
<div class="max-w-[1200px] mx-auto px-6">
<div class="grid md:grid-cols-4 gap-8 mb-12">
{# Column 1 — Brand + contact #}
<div>
<a href="/" class="inline-flex items-center gap-3" aria-label="DictIA — page d'accueil">
<img src="{{ url_for('static', filename='images/dictia-logo.png') }}"
alt=""
width="36"
height="36"
class="w-9 h-9"
aria-hidden="true">
<span class="font-black text-xl grad-text">DictIA</span>
</a>
<p class="text-sm text-white/70 mt-3">Transcription IA conforme Loi&nbsp;25, conçue au Québec.</p>
<address class="not-italic text-xs text-white/70 mt-4 leading-relaxed">
77&nbsp;ch. de la Seigneurie<br>
Inverness QC G0S&nbsp;1K0<br>
<a href="tel:+15819968471" class="hover:text-white">(581)&nbsp;996-8471</a><br>
<a href="mailto:info@dictia.ca" class="hover:text-white">info@dictia.ca</a>
</address>
</div>
{# Column 2 — Produit #}
<nav aria-label="Produit">
<p class="eyebrow text-white/70 mb-4">Produit</p>
<ul class="space-y-2 text-sm text-white/70">
<li><a href="/fonctionnalites" class="hover:text-white">Fonctionnalités</a></li>
<li><a href="/tarifs" class="hover:text-white">Tarifs</a></li>
</ul>
</nav>
{# Column 3 — Légal #}
<nav aria-label="Légal">
<p class="eyebrow text-white/70 mb-4">Légal</p>
<ul class="space-y-2 text-sm text-white/70">
<li><a href="/conformite" class="hover:text-white">Conformité</a></li>
<li><a href="/legal/conditions" class="hover:text-white">Conditions d'utilisation</a></li>
<li><a href="/legal/confidentialite" class="hover:text-white">Confidentialité (Loi&nbsp;25)</a></li>
<li><a href="/legal/cookies" class="hover:text-white">Cookies</a></li>
<li><a href="/legal/remboursement" class="hover:text-white">Remboursement</a></li>
<li><a href="/legal/accessibilite" class="hover:text-white">Accessibilité</a></li>
<li><a href="/legal/mentions" class="hover:text-white">Mentions légales</a></li>
<li><a href="https://gitea.dictia.ca/Innova-AI/dictia-public" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 hover:text-white">Code source AGPL<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M7 17l9.2-9.2M17 17V8h-9"/></svg><span class="sr-only">(s'ouvre dans un nouvel onglet)</span></a></li>
</ul>
</nav>
{# Column 4 — Compte #}
<nav aria-label="Compte">
<p class="eyebrow text-white/70 mb-4">Compte</p>
<ul class="space-y-2 text-sm text-white/70">
<li><a href="/login" class="hover:text-white">Connexion</a></li>
<li><a href="/signup" class="hover:text-white">Créer un compte</a></li>
<li><a href="/contact" class="hover:text-white">Contact</a></li>
</ul>
</nav>
</div>
<div class="pt-8 border-t border-white/[0.045] flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-white/70">
<p>© 2026 DictIA Inc. · AGPL v3 · Fait au Québec</p>
<div class="flex gap-4">
<a href="https://www.linkedin.com/company/dictiaqc" rel="noopener" target="_blank" class="hover:text-white">LinkedIn</a>
<a href="https://www.facebook.com/dictiaqc" rel="noopener" target="_blank" class="hover:text-white">Facebook</a>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,198 @@
{# Single source of truth for the v7.0 pricing — used by landing.html#tarifs and /tarifs page.
When prices change, edit ONLY this file (and src/billing/plans.py for Stripe IDs).
v7.0 — 3 forfaits Cloud (en rangée) + 1 DictIA LOCAL (bloc dédié) + 1 soumission :
- Cloud BASIC 189 $/mois (no setup)
- Cloud ESSENTIEL 349 $/mois (no setup)
- Cloud PRO 549 $/mois + 485 $ onboarding (recommended)
- DictIA LOCAL 5 998 $ An 1 puis 500 $/an dès An 2 (bloc large dédié, "Vous en êtes propriétaire")
- Pro+ soumission personnalisée → /contact?pro-plus=1
Common to all forfaits :
WhisperX Large-v3 (99%+ · 99+ langues), pyannote diarisation, Mistral résumés,
exports SRT/VTT/TXT/JSON/DOCX, Loi 25 conforme, OVH Beauharnois (Cloud) ou local. #}
{% from 'macros/pricing_card.html' import pricing_card %}
{% from 'macros/button.html' import button %}
{%- set _baseline_features_cloud = [
'WhisperX Large-v3 · 99&nbsp;%+ précision · 99+ langues',
'Diarisation pyannote (qui parle)',
'Résumés IA + Points d&rsquo;action (Mistral Nemo 12B)',
'Exports SRT, VTT, TXT, JSON, DOCX',
'Hébergement OVH Beauharnois (QC)',
'Conforme Loi&nbsp;25 · Anti-DDoS · Backups quotidiens',
'Aucune limite utilisateurs'
] -%}
{# === Ligne 1 — 3 forfaits Cloud (1/2/3 cols responsive) === #}
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch">
{{ pricing_card(
slug='cloud-basic',
name='Cloud BASIC',
badge='Cloud · Souverain QC',
target='Solopreneur · petite équipe · usage occasionnel à régulier.',
monthly=189,
capacity_audio='~165&nbsp;h audio/mois',
capacity_storage='100&nbsp;Go',
gpu='NVIDIA L4 partagé',
features=_baseline_features_cloud,
cta_label='Démarrer en Cloud'
) }}
{{ pricing_card(
slug='cloud-essentiel',
name='Cloud ESSENTIEL',
badge='Cloud · Souverain QC',
target='Cabinet en croissance · usage quotidien soutenu.',
monthly=349,
capacity_audio='~330&nbsp;h audio/mois',
capacity_storage='200&nbsp;Go',
gpu='NVIDIA L4 partagé étendu',
features=_baseline_features_cloud,
cta_label='Choisir Essentiel'
) }}
{{ pricing_card(
slug='cloud-pro',
name='Cloud PRO',
badge='Cloud · Souverain QC',
recommended=True,
target='Organisation établie · usage intensif multi-postes.',
setup=485,
monthly=549,
capacity_audio='~660&nbsp;h audio/mois',
capacity_storage='500&nbsp;Go',
gpu='NVIDIA L4 dédié priorité',
features=_baseline_features_cloud + [
'GPU dédié priorité (latence garantie)',
'Onboarding assisté inclus'
],
cta_label='Commander Pro'
) }}
</div>
{# === Bloc 2 — DictIA LOCAL (large, distinctif, pleine largeur) === #}
<section class="mt-12 relative overflow-hidden bg-brand-navy2 border border-brand-border rounded p-8 md:p-12"
aria-labelledby="dictia-local-title">
{# Decorative orbs background — purely decorative, hidden from AT #}
<div class="absolute -top-32 -right-32 w-[500px] h-[500px] rounded-full pointer-events-none"
style="background: radial-gradient(circle, rgba(37,99,235,0.10) 0%, transparent 70%); filter: blur(60px);" aria-hidden="true"></div>
<div class="absolute -bottom-32 -left-32 w-[400px] h-[400px] rounded-full pointer-events-none"
style="background: radial-gradient(circle, rgba(192,38,211,0.08) 0%, transparent 70%); filter: blur(60px);" aria-hidden="true"></div>
<div class="relative grid lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)] gap-10 lg:gap-12 items-center">
{# === LEFT — copy + checkmarks === #}
<div>
<div class="flex items-center gap-2 mb-4 flex-wrap">
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-brand-b1/10 border border-brand-b1/30 text-xs font-semibold text-brand-b1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5" aria-hidden="true"><path d="M12 2C8 2 5 5 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-4-3-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
Au Québec
</span>
<span class="text-xs text-white/60">par InnovA AI</span>
</div>
<p class="eyebrow grad-text mb-2">DictIA LOCAL · Serveur souverain</p>
<h3 id="dictia-local-title" class="text-3xl md:text-4xl font-black text-white mb-4 leading-tight">
Vous en êtes <span class="grad-text">propriétaire</span>.
</h3>
<p class="text-base text-white/75 mb-6 leading-relaxed max-w-xl">
On vous vend, configure et installe votre serveur IA directement dans vos locaux. Vous êtes propriétaire du matériel. <strong class="text-white">Vos données ne quittent jamais votre bureau.</strong>
</p>
<ul class="space-y-3 mb-6" role="list">
{% for bullet in [
('PC + GPU RTX vous appartient', 'pas de location, pas d&rsquo;abonnement matériel'),
('Traitement 100&nbsp;% local', 'aucun transit réseau, fonctionne hors-ligne'),
('Assemblé et configuré au Québec par InnovA AI', 'support local inclus'),
('On vient l&rsquo;installer chez vous', 'formation incluse, opérationnel le jour&nbsp;1'),
('Achat direct sans appel d&rsquo;offres si &lt;&nbsp;34&nbsp;700&nbsp;$', 'DictIA LOCAL s&rsquo;y qualifie')
] %}
<li class="flex items-start gap-3 text-sm text-white/80">
<span class="flex-shrink-0 w-5 h-5 grad-bg flex items-center justify-center mt-0.5" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="w-3 h-3 text-white"><path d="M5 13l4 4L19 7"/></svg>
</span>
<span><strong class="text-white">{{ bullet[0] | safe }}</strong> — {{ bullet[1] | safe }}</span>
</li>
{% endfor %}
</ul>
<div class="flex flex-wrap gap-3 items-center">
{{ button('Voir les serveurs disponibles', href='/contact?plan=dictia-local', variant='primary', size='md', icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>') }}
<span class="text-sm text-white/60">5&nbsp;998&nbsp;$ An&nbsp;1 · 500&nbsp;$/an dès An&nbsp;2</span>
</div>
</div>
{# === RIGHT — server visual mockup === #}
<div class="relative">
<div class="bg-brand-navy3/60 border border-brand-b1/20 rounded p-6 backdrop-blur-sm">
<p class="eyebrow text-white/60 mb-3 text-center">GPU RTX — DictIA LOCAL</p>
<div class="flex flex-col items-center gap-4">
{# Server icon SVG (rack stylisé) — purely decorative #}
<div class="w-32 h-32 grad-bg flex items-center justify-center relative" aria-hidden="true">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.5" class="w-16 h-16 text-white">
<rect x="8" y="10" width="48" height="14" rx="0"/>
<circle cx="14" cy="17" r="1.5" fill="currentColor"/>
<circle cx="20" cy="17" r="1.5" fill="currentColor"/>
<line x1="30" y1="17" x2="50" y2="17"/>
<rect x="8" y="28" width="48" height="14" rx="0"/>
<circle cx="14" cy="35" r="1.5" fill="currentColor"/>
<circle cx="20" cy="35" r="1.5" fill="currentColor"/>
<line x1="30" y1="35" x2="50" y2="35"/>
<rect x="8" y="46" width="48" height="14" rx="0"/>
<circle cx="14" cy="53" r="1.5" fill="currentColor"/>
<circle cx="20" cy="53" r="1.5" fill="currentColor"/>
<line x1="30" y1="53" x2="50" y2="53"/>
</svg>
</div>
<p class="text-center text-white font-bold text-base">Serveur DictIA</p>
<ul class="space-y-1.5 text-xs text-white/70 w-full" role="list">
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b2" aria-hidden="true"></span>
<span>Interface web</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b2" aria-hidden="true"></span>
<span>PC gaming haute performance</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b2" aria-hidden="true"></span>
<span>GPU RTX 5070&nbsp;Ti 16&nbsp;Go dédié IA</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b2" aria-hidden="true"></span>
<span>WhisperX + LLM Mistral 7B local</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b2" aria-hidden="true"></span>
<span>DictIA pré-installé</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1 h-1 rounded-full bg-brand-b3" aria-hidden="true"></span>
<span class="font-semibold text-white">Votre propriété</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
{# === Pro+ banner — soumission personnalisée pour grands volumes / SLA renforcé === #}
<div class="mt-10 max-w-5xl mx-auto p-6 bg-brand-navy text-white border border-brand-b2/30 rounded backdrop-blur-sm relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none opacity-60" aria-hidden="true"
style="background: radial-gradient(circle at 100% 0%, rgba(192,38,211,0.12) 0%, transparent 55%), radial-gradient(circle at 0% 100%, rgba(6,182,212,0.10) 0%, transparent 55%);"></div>
<div class="relative flex items-center justify-between flex-wrap gap-6">
<div class="flex-1 min-w-[260px]">
<p class="eyebrow grad-text mb-2 text-[11px]">Pro+ · Soumission personnalisée</p>
<h3 class="text-lg font-bold text-white mb-2">Au-delà de Cloud&nbsp;PRO&nbsp;?</h3>
<p class="text-sm text-white/75 leading-relaxed">
&gt;&nbsp;660&nbsp;h audio/mois · &gt;&nbsp;500&nbsp;Go stockage · 7+ utilisateurs intensifs · multi-sites · SLA&nbsp;99,9&nbsp;% · SOC&nbsp;2 Type&nbsp;I/II · PHIPA · PIPEDA Ontario · documentation gouv. (SEAO/MCN).
</p>
</div>
{{ button('Demander une soumission', href='/contact?pro-plus=1', variant='primary', size='md') }}
</div>
</div>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr-CA">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#060d1a">
<title>{% block title %}DictIA — Transcription IA conforme Loi 25 | Avocats, CPA, secteur public{% endblock %}</title>
<meta name="description" content="{% block description %}Transcription IA 100% locale, conforme Loi 25. Pour avocats, CPA, ChAD et 6 autres ordres professionnels. Hébergé au Québec, zéro Cloud Act.{% endblock %}">
<link rel="canonical" href="{% block canonical %}https://dictia.pages.dev{{ request.path }}{% endblock %}">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="{{ self.title() }}">
<meta property="og:description" content="{{ self.description() }}">
<meta property="og:image" content="{% block og_image %}https://dictia.pages.dev/static/images/og/og-default.png{% endblock %}">
<meta property="og:url" content="https://dictia.pages.dev{{ request.path }}">
<meta property="og:locale" content="fr_CA">
<meta property="og:site_name" content="DictIA">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ self.title() }}">
<meta name="twitter:description" content="{{ self.description() }}">
<meta name="twitter:image" content="{{ self.og_image() }}">
<!-- Preload critical fonts -->
<link rel="preload" href="/static/fonts/Inter-Variable.woff2" as="font" type="font/woff2" crossorigin>
<!-- Marketing CSS (Tailwind v4 buildé) -->
<link rel="stylesheet" href="/static/css/marketing.css">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/images/dictia-logo.png">
<link rel="alternate icon" type="image/svg+xml" href="/static/images/favicon.svg">
{% block schema %}{% endblock %}
{% block head_extra %}{% endblock %}
</head>
<body class="bg-white">
<!-- Glassmorphism header (FlexiHub style: 62px, navy/.97 + backdrop-blur-xl + 0.045 border) -->
<header class="fixed top-0 inset-x-0 z-50 h-[62px] bg-brand-navy/[0.97] backdrop-blur-xl border-b border-white/[0.045]">
<div class="max-w-[1200px] mx-auto h-full px-6 flex items-center justify-between">
<a href="/" class="flex items-center gap-3 leading-none" aria-label="DictIA — Transcription, accueil">
<img src="{{ url_for('static', filename='images/dictia-logo.png') }}"
alt=""
width="40"
height="40"
class="w-10 h-10 flex-shrink-0"
aria-hidden="true">
<span class="flex flex-col">
<span class="font-black text-xl tracking-tight grad-text">DictIA</span>
<span class="text-[10px] uppercase tracking-[0.2em] text-white font-medium mt-0.5">Transcription</span>
</span>
</a>
<nav class="hidden md:flex gap-8 text-sm font-medium text-white/80" aria-label="Navigation principale">
<a href="/fonctionnalites" class="hover:text-white transition">Fonctionnalités</a>
<a href="/tarifs" class="hover:text-white transition">Tarifs</a>
<a href="/contact" class="hover:text-white transition">Contact</a>
</nav>
<div class="flex items-center gap-3">
<a href="/login" class="text-sm font-medium text-white/80 hover:text-white">Connexion</a>
{% from 'macros/button.html' import button %}
{{ button('Démarrer', href='/signup', variant='primary', size='sm', icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>') }}
</div>
</div>
</header>
<main class="pt-[62px]">
{% block content %}{% endblock %}
</main>
{% include 'marketing/_footer.html' %}
<!-- Alpine.js for interactivity (FAQ accordion, ROI calculator, mobile menu) -->
<script src="/static/js/alpine.min.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,152 @@
{% extends 'marketing/base.html' %}
{% block title %}Conformité DictIA — Loi 25 (LPRPSP), LGGRI, AGPL v3, audit trail{% endblock %}
{% block description %}DictIA mappe son architecture aux exigences Loi 25 (LPRPSP), au cadre IA du secteur public québécois (LGGRI), avec hébergement OVH Beauharnois et code source AGPL v3 vérifiable.{% endblock %}
{% block content %}
{# ===== HEADER ===== #}
<section class="bg-brand-navy text-white py-20 overflow-hidden relative" aria-labelledby="page-title">
<div class="absolute top-1/3 left-1/4 w-[500px] h-[500px] rounded-full pointer-events-none" aria-hidden="true"
style="background: radial-gradient(circle, rgba(6,182,212,0.07) 0%, transparent 60%); filter: blur(60px);"></div>
<div class="relative max-w-[820px] mx-auto px-6 text-center">
<p class="eyebrow grad-text mb-4">CONFORMITÉ — FORTERESSE QUÉBÉCOISE</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Architecture <span class="grad-text">conçue avec</span> les exigences professionnelles québécoises.
</h1>
<p class="text-lg text-white/80">
Détails techniques, EFVP type, modèles de déclaration CAI&nbsp;: disponibles sur demande à <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
</p>
</div>
</section>
{# ===== 4 PILLARS (same as landing's Conformité section, content-identical for SEO single source of truth on /conformite landing page) ===== #}
<section class="bg-white py-20" aria-labelledby="pillars-title">
<div class="max-w-[1200px] mx-auto px-6">
<div class="text-center max-w-2xl mx-auto mb-12">
<p class="eyebrow grad-text mb-4">QUATRE PILIERS</p>
<h2 id="pillars-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
Pourquoi la conformité est <span class="grad-text">structurelle</span>, pas optionnelle.
</h2>
</div>
{# Icons (heroicons-style outline) — pin (QC), scale (Loi 25), building (Cadre IA), code (AGPL). #}
{%- set svg_pin = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>' -%}
{%- set svg_scale = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M12 3v18"/><path d="M5 7h14"/><path d="M5 7l-2 6a4 4 0 0 0 8 0L9 7"/><path d="M19 7l2 6a4 4 0 0 1-8 0l2-6"/><path d="M8 21h8"/></svg>' -%}
{%- set svg_building = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M3 21h18"/><path d="M5 21V8l7-4 7 4v13"/><path d="M9 21v-6h6v6"/><path d="M9 11h.01"/><path d="M15 11h.01"/></svg>' -%}
{%- set svg_code = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/><line x1="14" y1="4" x2="10" y2="20"/></svg>' -%}
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{% for card in [
{
'icon': svg_pin,
'title': 'Stockage OVH Beauharnois&nbsp;(QC)',
'desc': 'Stockage persistant chez OVHcloud Canada à Beauharnois, Québec. Traitement GPU temporaire sur GCP Toronto (Ontario)&nbsp;: RAM uniquement, durée maximale 5&nbsp;minutes par session, zéro persistance — encadré par EFVP signée. Données médicales et biométriques jamais hors du Canada.'
},
{
'icon': svg_scale,
'title': 'Mappé Loi&nbsp;25 (LPRPSP)',
'desc': 'Audit trail art.&nbsp;3.5, EFVP signées art.&nbsp;3.3 et 17 (GCP, HubSpot), registre des consentements art.&nbsp;14, déclaration CAI biométrie (formulaire K1) préparée. Modèles disponibles sur demande.'
},
{
'icon': svg_building,
'title': 'Compatible Cadre IA secteur public',
'desc': 'DictIA est conçu pour s\'inscrire dans le cadre de gestion des systèmes d\'IA du secteur public québécois (LGGRI). Documentation détaillée sur demande.'
},
{
'icon': svg_code,
'title': 'Code source AGPL&nbsp;v3 vérifiable',
'desc': 'Fork du projet open source Speakr — architecture entièrement auditable sur <a href="https://gitea.dictia.ca/Innova-AI/dictia-public" target="_blank" rel="noopener" class="underline hover:text-brand-navy">Gitea public</a>. Aucune boîte noire. Vos auditeurs peuvent examiner chaque ligne.'
}
] %}
<article class="bg-brand-bg p-6 rounded border border-brand-border">
<div class="w-10 h-10 grad-bg rounded-none mb-4 flex items-center justify-center text-white shadow-cta" aria-hidden="true">{{ card.icon | safe }}</div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">{{ card.title | safe }}</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed">{{ card.desc | safe }}</p>
</article>
{% endfor %}
</div>
</div>
</section>
{# ===== LOI 25 DETAIL ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="loi25-title">
<div class="max-w-[1060px] mx-auto px-6">
<div class="text-center max-w-2xl mx-auto mb-12">
<p class="eyebrow grad-text mb-4">LOI&nbsp;25 (LPRPSP)</p>
<h2 id="loi25-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
Trois articles centraux que DictIA adresse par construction.
</h2>
<p class="text-base text-brand-navy/80">
La <em>Loi sur la protection des renseignements personnels dans le secteur privé</em> (LPRPSP, communément appelée «&nbsp;Loi&nbsp;25&nbsp;») impose une discipline stricte sur les données biométriques et confidentielles. Les voix capturées en réunion en font partie. Voici comment notre architecture y répond.
</p>
</div>
<div class="space-y-6">
{% for art in [
{
'num': 'Art.&nbsp;3.3',
'title': 'Évaluation des facteurs relatifs à la vie privée (EFVP)',
'desc': 'Tout déploiement de DictIA dans un cabinet ou un organisme public déclenche une EFVP. Nous fournissons un modèle pré-rempli pour la voix professionnelle (catégories de données, finalités, mesures de sécurité, durée de conservation, transferts) — à compléter avec votre responsable de la protection des renseignements personnels (RPRP).'
},
{
'num': 'Art.&nbsp;3.5',
'title': 'Audit trail intégré',
'desc': 'Chaque enregistrement, écoute, export, partage ou suppression est journalisé&nbsp;: utilisateur, IP, date/heure UTC, action. Le journal est consultable par votre RPRP et exportable pour audits CAI ou ordres professionnels. Aucun moyen de désactiver le journal côté client.'
},
{
'num': 'Art.&nbsp;14',
'title': 'Consentement explicite et tracé',
'desc': 'Avant tout enregistrement, DictIA exige une confirmation que les participants ont consenti à l\'enregistrement et à la transcription IA. Le consentement est tracé dans le journal d\'audit. Vous pouvez configurer une demande de consentement automatique en début de session.'
}
] %}
<article class="bg-white p-6 rounded border border-brand-border">
<div class="flex flex-col md:flex-row md:items-start gap-4">
<div class="flex-shrink-0">
<span class="inline-block bg-brand-navy text-white text-xs font-black px-3 py-1.5 rounded-none">{{ art.num | safe }}</span>
</div>
<div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">{{ art.title | safe }}</h3>
<p class="text-sm text-brand-navy/80 leading-relaxed">{{ art.desc | safe }}</p>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</section>
{# ===== AGPL TRANSPARENCY ===== #}
<section class="bg-brand-navy text-white py-20" aria-labelledby="agpl-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<p class="eyebrow grad-text mb-4">AGPL&nbsp;V3 — TRANSPARENCE</p>
<h2 id="agpl-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4">
Code <span class="grad-text">vérifiable ligne par ligne</span>.
</h2>
<p class="text-base text-white/80 mb-8">
DictIA est publié sous licence <strong>GNU AGPL v3</strong>. Conséquence pratique&nbsp;: tout fork hébergé doit publier ses modifications sous la même licence. Vos auditeurs internes ou un tiers de confiance peuvent inspecter chaque ligne — modèle ML, pipeline audio, gestion d'identité, journal d'audit, exports. Aucune boîte noire propriétaire.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% from 'macros/button.html' import button %}
{{ button('Code source sur Gitea', href='https://gitea.dictia.ca/Innova-AI/dictia-public', variant='primary', size='lg', icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M7 17l9.2-9.2M17 17V8h-9"/></svg>', target='_blank', rel='noopener') }}
{{ button('Comprendre AGPL v3', href='https://www.gnu.org/licenses/agpl-3.0.fr.html', variant='ghost', size='lg', target='_blank', rel='noopener') }}
</div>
</div>
</section>
{# ===== CTA ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="conformite-cta-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<h2 id="conformite-cta-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-6 text-brand-navy">
Une <span class="grad-text">question de conformité</span>&nbsp;?
</h2>
<p class="text-lg text-brand-navy/80 mb-8">
Nous accompagnons votre RPRP, votre comptable d'ordre ou votre service juridique dans l'évaluation. Modèle d'EFVP, registre de consentements et exemple de déclaration CAI sur demande.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% from 'macros/button.html' import button %}
{{ button('Demander un dossier conformité', href='mailto:info@dictia.ca?subject=Demande%20dossier%20conformit%C3%A9', variant='primary', size='lg', icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>') }}
{{ button('Voir les forfaits', href='/tarifs', variant='secondary', size='lg') }}
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends 'marketing/base.html' %}
{% block title %}Contact DictIA — info@dictia.ca, (581) 996-8471, Inverness QC{% endblock %}
{% block description %}Joignez DictIA Inc. à info@dictia.ca ou (581) 996-8471. Bureau au 77 ch. de la Seigneurie, Inverness QC. Réponse sous 2 jours ouvrables.{% endblock %}
{% block content %}
{# ===== HEADER ===== #}
<section class="bg-brand-navy text-white py-20" aria-labelledby="page-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<p class="eyebrow grad-text mb-4">CONTACT</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Parlons <span class="grad-text">de votre projet</span>.
</h1>
<p class="text-lg text-white/80">
Réponse sous 2&nbsp;jours ouvrables. Pour les urgences techniques des clients existants, voyez la section Support de la console DictIA.
</p>
</div>
</section>
{# ===== CONTACT METHODS ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="methods-title">
<div class="max-w-[1060px] mx-auto px-6">
<h2 id="methods-title" class="sr-only">Trois manières de nous joindre</h2>
<div class="grid md:grid-cols-3 gap-6">
{# Email card #}
<article class="bg-white p-8 rounded border border-brand-border flex flex-col">
<div class="w-12 h-12 grad-bg rounded-none mb-4 flex items-center justify-center text-white shadow-cta" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">Courriel</h3>
<p class="text-sm text-brand-navy/80 mb-4 leading-relaxed flex-grow">
Privilégiez le courriel pour&nbsp;: pré-inscription, devis, démonstration, dossier de conformité, partenariats.
</p>
<a href="mailto:info@dictia.ca" class="grad-text font-semibold text-base hover:underline">info@dictia.ca</a>
</article>
{# Phone card #}
<article class="bg-white p-8 rounded border border-brand-border flex flex-col">
<div class="w-12 h-12 grad-bg rounded-none mb-4 flex items-center justify-center text-white shadow-cta" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">Téléphone</h3>
<p class="text-sm text-brand-navy/80 mb-4 leading-relaxed flex-grow">
Du lundi au vendredi, 9&nbsp;h à 17&nbsp;h (heure de l'Est). Laissez un message en dehors de ces heures.
</p>
<a href="tel:+15819968471" class="grad-text font-semibold text-base hover:underline">(581)&nbsp;996-8471</a>
</article>
{# Mailing address card #}
<article class="bg-white p-8 rounded border border-brand-border flex flex-col">
<div class="w-12 h-12 grad-bg rounded-none mb-4 flex items-center justify-center text-white shadow-cta" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">Bureau</h3>
<p class="text-sm text-brand-navy/80 mb-4 leading-relaxed flex-grow">
Sur rendez-vous uniquement. Visites en personne pour démonstrations DictIA&nbsp;LOCAL et déploiements Cloud&nbsp;PRO corporatifs.
</p>
<address class="not-italic text-sm text-brand-navy/80 leading-relaxed">
77&nbsp;ch. de la Seigneurie<br>
Inverness QC G0S&nbsp;1K0
</address>
</article>
</div>
</div>
</section>
{# ===== USE-CASE SHORTCUTS ===== #}
<section class="bg-white py-20" aria-labelledby="shortcuts-title">
<div class="max-w-[820px] mx-auto px-6">
<div class="text-center mb-10">
<p class="eyebrow grad-text mb-4">RACCOURCIS</p>
<h2 id="shortcuts-title" class="text-[clamp(2rem,3vw,2.5rem)] font-black mb-4 text-brand-navy">
Un sujet précis&nbsp;?
</h2>
<p class="text-base text-brand-navy/80">
Pré-remplissez le sujet du courriel selon votre besoin&nbsp;:
</p>
</div>
{# Shortcut icons (heroicons-style outline). Each is a self-contained inline SVG passed via | safe in the loop. #}
{%- set svg_target = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>' -%}
{%- set svg_office = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="9" y1="6" x2="9" y2="6"/><line x1="15" y1="6" x2="15" y2="6"/><line x1="9" y1="10" x2="9" y2="10"/><line x1="15" y1="10" x2="15" y2="10"/><line x1="9" y1="14" x2="9" y2="14"/><line x1="15" y1="14" x2="15" y2="14"/><path d="M10 22v-4h4v4"/></svg>' -%}
{%- set svg_play = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><polygon points="10 9 16 12 10 15 10 9" fill="currentColor"/></svg>' -%}
{%- set svg_scale_sm = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M12 3v18"/><path d="M5 7h14"/><path d="M5 7l-2 6a4 4 0 0 0 8 0L9 7"/><path d="M19 7l2 6a4 4 0 0 1-8 0l2-6"/><path d="M8 21h8"/></svg>' -%}
{%- set svg_handshake = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M16 12l-4-4-4 4"/><path d="M3 13l5 5 4-4 4 4 5-5"/><path d="M3 9l9 9 9-9"/></svg>' -%}
{%- set svg_news = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true"><path d="M4 4h13a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2z"/><path d="M19 8h2a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2"/><line x1="8" y1="9" x2="15" y2="9"/><line x1="8" y1="13" x2="15" y2="13"/><line x1="8" y1="17" x2="12" y2="17"/></svg>' -%}
<div class="grid sm:grid-cols-2 gap-3">
{% for shortcut in [
{'label': 'Pré-inscription DictIA', 'subject': 'Pr%C3%A9-inscription%20DictIA', 'icon': svg_target},
{'label': 'Devis multi-sites', 'subject': 'Devis%20multi-sites', 'icon': svg_office},
{'label': 'Demande de démonstration', 'subject': 'Demande%20de%20d%C3%A9monstration', 'icon': svg_play},
{'label': 'Dossier conformité Loi 25', 'subject': 'Dossier%20conformit%C3%A9%20Loi%2025', 'icon': svg_scale_sm},
{'label': 'Partenariat / intégration', 'subject': 'Partenariat%20/%20int%C3%A9gration', 'icon': svg_handshake},
{'label': 'Question média / presse', 'subject': 'Question%20m%C3%A9dia', 'icon': svg_news}
] %}
<a href="mailto:info@dictia.ca?subject={{ shortcut.subject }}"
class="flex items-center gap-3 bg-brand-bg p-4 rounded border border-brand-border hover:bg-white hover:border-brand-b1/30 transition-colors focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<span class="grad-text flex-shrink-0" aria-hidden="true">{{ shortcut.icon | safe }}</span>
<span class="text-sm font-semibold text-brand-navy">{{ shortcut.label | safe }}</span>
</a>
{% endfor %}
</div>
</div>
</section>
{# ===== PRE-LAUNCH NOTE ===== #}
<section class="bg-brand-bg py-12" aria-labelledby="prelaunch-note-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<h2 id="prelaunch-note-title" class="text-base font-bold text-brand-navy/80 mb-2">
Formulaire en ligne&nbsp;: bientôt disponible
</h2>
<p class="text-sm text-brand-navy/70">
Le formulaire de contact en ligne ouvrira au lancement (printemps&nbsp;2026). D'ici là, le courriel reste le canal officiel.
</p>
</div>
</section>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
{% extends 'marketing/base.html' %}
{% block title %}Tarifs DictIA — 3 Cloud + 1 serveur Local en CAD (Cloud Basic 189 $/mo · Essentiel 349 $ · Pro 549 $ · DictIA LOCAL 5 998 $){% endblock %}
{% block description %}Tarifs DictIA en CAD : Cloud Basic (189 $/mo), Cloud Essentiel (349 $/mo), Cloud Pro (549 $/mo + 485 $ onboarding) et DictIA LOCAL (5 998 $ An 1 puis 500 $/an, vous en êtes propriétaire). Aucune limite utilisateurs, taxes en sus.{% endblock %}
{% block content %}
{# ===== HEADER ===== #}
<section class="bg-brand-navy text-white py-20" aria-labelledby="page-title">
<div class="max-w-[820px] mx-auto px-6 text-center">
<p class="eyebrow grad-text mb-4">TARIFS</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Trois forfaits Cloud + DictIA&nbsp;LOCAL&nbsp;: <span class="grad-text">choisissez votre infrastructure</span>.
</h1>
<p class="text-lg text-white/80">
3 Cloud souverains hébergés au Québec + 1 serveur 100&nbsp;% local dont vous êtes propriétaire. Aucune limite utilisateurs, tarifs en CAD, taxes en sus (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%).
</p>
</div>
</section>
{# ===== 3 Cloud + DictIA LOCAL block + Pro+ ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="forfaits-title">
<div class="max-w-[1200px] mx-auto px-6">
<h2 id="forfaits-title" class="sr-only">Trois forfaits Cloud DictIA + DictIA LOCAL + Pro+ sur soumission</h2>
{% include 'marketing/_partials/_pricing_tiers.html' %}
</div>
</section>
{# ===== COMPARISON MATRIX ===== #}
<section class="bg-white py-20" aria-labelledby="matrix-title">
<div class="max-w-[1200px] mx-auto px-6">
<div class="text-center max-w-2xl mx-auto mb-12">
<p class="eyebrow grad-text mb-4">COMPARAISON DÉTAILLÉE</p>
<h2 id="matrix-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
Détails par forfait.
</h2>
</div>
<div class="overflow-x-auto rounded border border-brand-border">
<table class="w-full min-w-[820px] text-sm">
<caption class="sr-only">Comparaison détaillée des 4 forfaits DictIA sur 9 caractéristiques techniques et opérationnelles</caption>
<thead class="bg-brand-bg">
<tr>
<th scope="col" class="text-left p-4 font-bold text-brand-navy">Caractéristique</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud BASIC</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud ESSENTIEL</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud PRO</th>
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA LOCAL</th>
</tr>
</thead>
{%- set svg_check = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 inline-block text-brand-b3" aria-label="Inclus" role="img"><path d="M5 13l4 4L19 7"/></svg>' -%}
<tbody class="divide-y divide-brand-border">
{% for row in [
{'name': 'Hébergement', 'basic': 'OVH Beauharnois (QC)', 'ess': 'OVH Beauharnois (QC)', 'pro': 'OVH Beauharnois (QC)', 'local': 'Chez le client (100&nbsp;% hors-ligne)'},
{'name': 'GPU', 'basic': 'NVIDIA L4 partagé', 'ess': 'L4 partagé étendu', 'pro': 'L4 dédié priorité', 'local': 'RTX 5070&nbsp;Ti 16&nbsp;Go'},
{'name': 'Capacité audio', 'basic': '~165&nbsp;h/mois', 'ess': '~330&nbsp;h/mois', 'pro': '~660&nbsp;h/mois', 'local': '~1&nbsp;100&nbsp;h/mois'},
{'name': 'Stockage', 'basic': '100&nbsp;Go', 'ess': '200&nbsp;Go', 'pro': '500&nbsp;Go', 'local': '2&nbsp;To SSD'},
{'name': 'Utilisateurs', 'basic': 'Aucune limite', 'ess': 'Aucune limite', 'pro': 'Aucune limite', 'local': 'Aucune limite'},
{'name': 'Diarisation pyannote', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check},
{'name': 'Résumés IA + Points d&rsquo;action','basic': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'ess': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'pro': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'local': svg_check ~ '<span class="ml-1 text-xs">(Mistral 7B local)</span>'},
{'name': 'Conformité Loi&nbsp;25', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check ~ '<span class="ml-1 text-xs">+ 100&nbsp;% hors-ligne</span>'},
{'name': 'SLA', 'basic': '99,5&nbsp;%', 'ess': '99,5&nbsp;%', 'pro': '99,5&nbsp;%', 'local': '—&nbsp;(resp. client)'},
{'name': 'Délai de mise en service', 'basic': '48&nbsp;h', 'ess': '48&nbsp;h', 'pro': '48&nbsp;h + onboarding', 'local': '~2&nbsp;semaines'}
] %}
<tr>
<th scope="row" class="text-left p-4 font-semibold text-brand-navy/80">{{ row.name | safe }}</th>
<td class="p-4 text-center text-brand-navy/80">{{ row.basic | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.ess | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.pro | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.local | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-xs text-brand-navy/70 mt-6 text-center max-w-3xl mx-auto">
Caractéristiques au 2026-04-27. Pour un volume &gt;&nbsp;660&nbsp;h audio/mois, multi-sites ou SLA&nbsp;99,9&nbsp;%, demandez une <a href="/contact?pro-plus=1" class="grad-text font-semibold hover:underline">soumission Pro+</a>. Questions&nbsp;: <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
</p>
</div>
</section>
{# ===== TARIFICATION FAQ ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="tarifs-faq-title">
<div class="max-w-[820px] mx-auto px-6">
<div class="text-center mb-10">
<p class="eyebrow grad-text mb-4">QUESTIONS DE TARIFICATION</p>
<h2 id="tarifs-faq-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">Vos questions sur les tarifs.</h2>
</div>
<div class="divide-y divide-brand-border border-y border-brand-border">
{% for item in [
{'q': 'Y a-t-il des frais cachés?', 'a': 'Non. Les tarifs affichés couvrent l\'utilisation de la capacité indiquée (audio mensuel, stockage) sans frais par utilisateur. Les seules variables sont&nbsp;: les taxes (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%) et, pour DictIA Local, le matériel inclus dans le 5&nbsp;998&nbsp;$ An&nbsp;1. Aucun frais par minute, par mot, par locuteur.'},
{'q': 'Puis-je passer d\'un forfait à un autre?', 'a': 'Oui, en tout temps. Les passages entre Cloud Basic, Essentiel et Pro sont supportés (prorata Stripe). Migration Cloud → DictIA Local (et inversement) sur demande, sans frais. Détails dans nos <a href="/legal/conditions" class="grad-text underline">conditions d\'utilisation</a>.'},
{'q': 'Que comprend le 5&nbsp;998&nbsp;$ de DictIA Local?', 'a': 'Le forfait DictIA Local An&nbsp;1 inclut&nbsp;: PC + GPU RTX 5070&nbsp;Ti 16&nbsp;Go + 2&nbsp;To SSD, installation sur site, configuration sécurité, formation équipe, et la première année de licence logicielle. Dès l\'An&nbsp;2, seul le renouvellement annuel de 500&nbsp;$/an (mises à jour + support) est facturé.'},
{'q': 'Comment fonctionne le 485&nbsp;$ d\'onboarding Cloud Pro?', 'a': 'Le forfait Cloud Pro inclut un onboarding assisté unique (485&nbsp;$) couvrant&nbsp;: configuration des comptes, importation des hotwords métier, formation équipe (1&nbsp;h visioconférence), tests de charge initiaux. Cloud Basic et Cloud Essentiel sont en self-service (aucun frais d\'installation).'},
{'q': 'Comment fonctionne la facturation TPS/TVQ?', 'a': 'DictIA Inc. est inscrite TPS et TVQ. Les factures détaillent les taxes selon votre province de facturation. Pour les organismes exemptés (organismes publics, etc.), envoyez votre attestation à info@dictia.ca avant l\'inscription.'},
{'q': 'Existe-t-il un tarif annuel sur les forfaits Cloud?', 'a': 'Oui. Les paiements annuels sur Cloud Basic, Essentiel et Pro bénéficient d\'une remise de 15&nbsp;% (équivalent ~10&nbsp;mois payés au lieu de 12). Sélectionnable au moment du paiement Stripe.'},
{'q': 'Quand demander une soumission Pro+?', 'a': 'Pro+ s\'adresse aux organisations ayant besoin de&nbsp;: &gt;&nbsp;660&nbsp;h audio/mois, &gt;&nbsp;500&nbsp;Go de stockage, 7+ utilisateurs simultanés intensifs, multi-sites, SLA renforcé 99,9&nbsp;%, audits SOC&nbsp;2 Type&nbsp;I/II, conformité PHIPA / PIPEDA Ontario, ou documentation gouvernementale (SEAO/MCN). Demandez une <a href="/contact?pro-plus=1" class="grad-text underline">soumission personnalisée</a>.'}
] %}
<div x-data="{ open: false }" class="py-2">
<h3>
<button type="button"
class="w-full flex items-center justify-between gap-4 py-4 text-left hover:bg-brand-navy/[0.03] transition-colors rounded-none px-2 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
@click="open = !open"
:aria-expanded="open.toString()"
aria-controls="tarifs-faq-panel-{{ loop.index }}">
<span class="font-semibold text-brand-navy text-base">{{ item.q | safe }}</span>
<span class="grad-text text-2xl flex-shrink-0 transition-transform"
:class="open ? 'rotate-45' : ''" aria-hidden="true">+</span>
</button>
</h3>
<div id="tarifs-faq-panel-{{ loop.index }}" x-show="open" x-transition.opacity.duration.200ms>
<p class="px-2 pb-4 text-sm text-brand-navy/80 leading-relaxed">{{ item.a | safe }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{# ===== CTA ===== #}
<section class="relative bg-brand-navy text-white py-20 overflow-hidden" aria-labelledby="tarifs-cta-title">
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
<div class="absolute top-1/3 left-1/3 w-[500px] h-[500px] rounded-full"
style="background: radial-gradient(circle, rgba(37,99,235,0.12) 0%, transparent 60%); filter: blur(50px);"></div>
</div>
<div class="relative max-w-[820px] mx-auto px-6 text-center">
<h2 id="tarifs-cta-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-6">
Une question sur votre <span class="grad-text">forfait idéal</span>&nbsp;?
</h2>
<p class="text-lg text-white/80 mb-8">
Nous accompagnons chaque organisation dans le choix du forfait le mieux adapté à sa volumétrie, ses contraintes réglementaires et son infrastructure existante. Aucune pression commerciale.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% from 'macros/button.html' import button %}
{{ button('Discuter avec notre équipe', href='mailto:info@dictia.ca?subject=Question%20tarifs%20DictIA', variant='primary', size='lg', icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>') }}
{{ button('Voir les fonctionnalités', href='/fonctionnalites', variant='ghost', size='lg') }}
</div>
</div>
</section>
{% endblock %}

View File

@@ -1,164 +1,105 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Loading overlay to prevent FOUC --> {% block title %}Créer un compte — DictIA{% endblock %}
{% include 'includes/loading_overlay.html' %} {% block description %}Créez votre compte DictIA. Conformité Loi&nbsp;25 du Québec, hébergement local, consentement granulaire.{% endblock %}
<script> {% block content %}
// Function to apply the theme based on localStorage <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="signup-title">
function applyTheme() { <div class="max-w-md mx-auto bg-white p-8 rounded border border-brand-border shadow-cta">
// Guard against early execution <h1 id="signup-title" class="text-3xl font-black text-brand-navy mb-2">Créer un compte</h1>
if (!document.documentElement) return; <p class="text-sm text-brand-navy/70 mb-6">{{ "Conformité Loi&nbsp;25 incluse — consentement granulaire, hébergement au Québec." | safe }}</p>
// Apply dark mode {% with messages = get_flashed_messages(with_categories=true) %}
const savedMode = localStorage.getItem('darkMode'); {% if messages %}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; {% for category, message in messages %}
if (savedMode === 'true' || (savedMode === null && prefersDark)) { <div role="alert" class="mb-3 p-3 rounded text-sm
document.documentElement.classList.add('dark'); {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
} else { {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
document.documentElement.classList.remove('dark'); {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
} {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
// Apply color scheme <form method="POST" action="{{ url_for('auth.signup') }}" class="space-y-4" novalidate>
const savedScheme = localStorage.getItem('colorScheme') || 'blue'; {{ form.hidden_tag() }}
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
// Remove all other theme classes <div>
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal']; <label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
themeClasses.forEach(theme => { {{ form.email(id='email', type='email', autocomplete='email', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
document.documentElement.classList.remove(`theme-light-${theme}`); {% if form.email.errors %}<p class="text-xs text-red-700 mt-1">{{ form.email.errors[0] }}</p>{% endif %}
document.documentElement.classList.remove(`theme-dark-${theme}`); </div>
});
// Add the correct theme class <div>
if (savedScheme !== 'blue') { <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
document.documentElement.classList.add(themePrefix + savedScheme); {{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'aria-describedby':'password-help', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
} {% if form.password.errors %}<p class="text-xs text-red-900 mt-1">{{ form.password.errors[0] }}</p>{% endif %}
} <p id="password-help" class="text-xs text-brand-navy/70 mt-1">8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p>
applyTheme(); </div>
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> <div>
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <label for="confirm_password" class="block text-sm font-medium text-brand-navy mb-1">Confirmer le mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Create an Account</h2> {{ form.confirm_password(id='confirm_password', autocomplete='new-password', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.confirm_password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.confirm_password.errors[0] }}</p>{% endif %}
</div>
{% with messages = get_flashed_messages(with_categories=true) %} <div class="grid grid-cols-2 gap-3">
{% if messages %} <div>
{% for category, message in messages %} <label for="first_name" class="block text-sm font-medium text-brand-navy mb-1">Prénom <span class="text-red-600" aria-hidden="true">*</span></label>
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}"> {{ form.first_name(id='first_name', autocomplete='given-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{{ message }} {% if form.first_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.first_name.errors[0] }}</p>{% endif %}
</div> </div>
{% endfor %} <div>
{% endif %} <label for="last_name" class="block text-sm font-medium text-brand-navy mb-1">Nom <span class="text-red-600" aria-hidden="true">*</span></label>
{% endwith %} {{ form.last_name(id='last_name', autocomplete='family-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.last_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.last_name.errors[0] }}</p>{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('auth.register') }}"> <div>
{{ form.hidden_tag() }} <label for="cabinet" class="block text-sm font-medium text-brand-navy mb-1">Cabinet / Organisation</label>
{{ form.cabinet(id='cabinet', autocomplete='organization', **{'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.cabinet.errors %}<p class="text-xs text-red-700 mt-1">{{ form.cabinet.errors[0] }}</p>{% endif %}
</div>
<div class="mb-4"> <div>
{{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} <label for="ordre_pro" class="block text-sm font-medium text-brand-navy mb-1">Ordre professionnel</label>
{% if form.username.errors %} {{ form.ordre_pro(id='ordre_pro', **{'class':'w-full px-3 py-2 border border-brand-border rounded-none text-brand-navy bg-white focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} </div>
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.username.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="mb-4"> {# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} <fieldset class="space-y-3 pt-4 mt-2 border-t border-brand-border">
{% if form.email.errors %} <legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi&nbsp;25" | safe }}</legend>
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.email.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="mb-4"> <label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} {{ form.consent_cgu(id='consent_cgu', required=true, **{'aria-required':'true', 'class':'mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.password.errors %} <span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} </label>
<div class="text-[var(--text-danger)] text-xs mt-1"> {% if form.consent_cgu.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_cgu.errors[0] }}</p>{% endif %}
{% for error in form.password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p>
</div>
<div class="mb-6"> <label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} {{ form.consent_confidentialite(id='consent_confidentialite', required=true, **{'aria-required':'true', 'class':'mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.confirm_password.errors %} <span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} </label>
<div class="text-[var(--text-danger)] text-xs mt-1"> {% if form.consent_confidentialite.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_confidentialite.errors[0] }}</p>{% endif %}
{% for error in form.confirm_password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="flex flex-col space-y-4"> <label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }} {{ form.consent_marketing(id='consent_marketing', **{'class':'mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte de recevoir des communications marketing (optionnel, désactivable à tout moment).</span>
</label>
<div class="text-center text-sm text-[var(--text-muted)]"> <label for="consent_analytics" class="flex items-start gap-2 text-sm text-brand-navy/90">
<span>Already have an account?</span> {{ form.consent_analytics(id='consent_analytics', **{'class':'mt-1 rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Login here</a> <span>J'accepte les statistiques d'usage anonymisées (optionnel, désactivable à tout moment).</span>
</div> </label>
</div> </fieldset>
</form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> {{ form.submit(**{'class':'w-full grad-bg text-white font-semibold py-3 rounded-none shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> </form>
</footer>
</div>
<script> <p class="text-center text-sm text-brand-navy/70 mt-6">Déjà un compte ? <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">Se connecter</a></p>
// Hide loading overlay when page is ready </div>
document.addEventListener('DOMContentLoaded', function() { </section>
if (window.AppLoader) { {% endblock %}
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,78 @@
"""Windows manual driver for tests/test_email_service_dictia.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_email_service_dictia_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
# Avoid sys.exit(1) in src/config/app_config.py legacy validation.
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
# Disable rate limits for forgot_password endpoint test.
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_email_service_dictia',
os.path.join(HERE, 'test_email_service_dictia.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,74 @@
"""Windows manual driver for tests/test_legal_pages.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_legal_pages_windows.py
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Test-friendly env defaults
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-legal')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_legal_pages',
os.path.join(HERE, 'test_legal_pages.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,81 @@
"""Windows manual driver for tests/test_oauth_magic_link.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_oauth_magic_link_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-oauth')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Pre-set OAuth env vars so init_oauth_providers registers clients at app boot.
os.environ.setdefault('MS_CLIENT_ID', 'test-ms-client-id')
os.environ.setdefault('MS_CLIENT_SECRET', 'test-ms-client-secret')
os.environ.setdefault('GOOGLE_CLIENT_ID', 'test-google-client-id')
os.environ.setdefault('GOOGLE_CLIENT_SECRET', 'test-google-client-secret')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_oauth_magic_link',
os.path.join(HERE, 'test_oauth_magic_link.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,74 @@
"""Windows manual driver for tests/test_stripe_checkout.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_stripe_checkout_windows.py
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Test-friendly env defaults
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-stripe')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_stripe_checkout',
os.path.join(HERE, 'test_stripe_checkout.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,74 @@
"""Windows manual driver for tests/test_stripe_webhook.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_stripe_webhook_windows.py
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Test-friendly env defaults
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webhook')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_stripe_webhook',
os.path.join(HERE, 'test_stripe_webhook.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,77 @@
"""Windows manual driver for tests/test_totp_mfa.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_totp_mfa_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-totp')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_totp_mfa',
os.path.join(HERE, 'test_totp_mfa.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,77 @@
"""Windows manual driver for tests/test_webauthn_passkey.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_webauthn_passkey_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webauthn')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_webauthn_passkey',
os.path.join(HERE, 'test_webauthn_passkey.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

25
tests/conftest.py Normal file
View File

@@ -0,0 +1,25 @@
"""Test bootstrap — Windows shim for fcntl (used by src/init_db.py on POSIX).
Allows running tests on Windows even though the production app targets Linux.
Mirrors the stub used by serve_marketing.py for local preview.
"""
import os
import sys
import types
# Stub fcntl BEFORE pytest collects any test that imports src.app
if sys.platform.startswith('win') and 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_a, **_kw: None
fcntl_stub.fcntl = lambda *_a, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# Minimal env so src/config/app_config.py doesn't sys.exit on missing config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://local-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'local-stub')

View File

@@ -0,0 +1,62 @@
"""Tests for Phase 1 blueprint registration (B-1.2).
Verifies that the 3 new marketing-redesign blueprints (marketing, billing,
legal) register correctly on the global Flask app, in addition to the
existing api/auth/recordings/etc. blueprints.
Pattern: no conftest.py, env vars set at module load time, then import
src.app.app directly. Mirrors the convention used by tests/test_audit.py.
"""
import os
import sys
# Add the parent directory to the path to import app (mirrors test_audit.py)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-for-blueprint-registration')
from src.app import app # noqa: E402
def test_marketing_blueprint_registered():
assert 'marketing' in app.blueprints, (
f"Expected marketing blueprint, found: {list(app.blueprints.keys())}"
)
def test_billing_blueprint_registered():
assert 'billing' in app.blueprints, (
f"Expected billing blueprint, found: {list(app.blueprints.keys())}"
)
def test_legal_blueprint_registered():
assert 'legal' in app.blueprints, (
f"Expected legal blueprint, found: {list(app.blueprints.keys())}"
)
def test_marketing_landing_route_exists():
"""Marketing blueprint must expose root '/' route."""
rules = [str(r) for r in app.url_map.iter_rules() if r.endpoint.startswith('marketing.')]
assert any(r == '/' for r in rules), (
f"Expected marketing root route '/', found: {rules}"
)
def test_legal_blueprint_has_url_prefix():
"""Legal blueprint must be mounted with /legal url_prefix."""
assert 'legal' in app.blueprints
assert app.blueprints['legal'].url_prefix == '/legal', (
f"Expected legal blueprint url_prefix='/legal', got {app.blueprints['legal'].url_prefix!r}"
)
def test_billing_blueprint_has_url_prefix():
"""Billing blueprint must be mounted with /checkout url_prefix."""
assert 'billing' in app.blueprints
assert app.blueprints['billing'].url_prefix == '/checkout', (
f"Expected billing blueprint url_prefix='/checkout', got {app.blueprints['billing'].url_prefix!r}"
)

250
tests/test_consent_log.py Normal file
View File

@@ -0,0 +1,250 @@
"""Tests for the ConsentLog model — B-2.1 Loi 25 audit trail."""
import os
import sys
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
from src.app import app, db # noqa: E402
from src.models.user import User # noqa: E402
from src.models.consent import ConsentLog # noqa: E402
def _make_user(username='alice', email='alice@example.com'):
user = User(username=username, email=email, password='x' * 60)
db.session.add(user)
db.session.commit()
return user
def test_consent_log_records_cgu_grant():
"""Granting CGU consent at signup creates a row with all audit fields."""
with app.app_context():
db.create_all()
try:
user = _make_user()
log = ConsentLog(
user_id=user.id,
consent_type='cgu',
version='1.0',
granted=True,
ip_address='192.0.2.1',
user_agent='Mozilla/5.0 (Windows NT 10.0)'
)
db.session.add(log)
db.session.commit()
assert ConsentLog.query.count() == 1
assert log.granted_at is not None
assert log.granted_at <= datetime.utcnow()
assert log.revoked_at is None
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_supports_4_consent_types():
"""All 4 Loi 25 consent types (cgu, confidentialite, marketing, analytics) are valid."""
with app.app_context():
db.create_all()
try:
user = _make_user()
for consent_type in ('cgu', 'confidentialite', 'marketing', 'analytics'):
log = ConsentLog(
user_id=user.id,
consent_type=consent_type,
version='1.0',
granted=True,
ip_address='192.0.2.1',
user_agent='Mozilla/5.0'
)
db.session.add(log)
db.session.commit()
types = sorted([l.consent_type for l in ConsentLog.query.all()])
assert types == ['analytics', 'cgu', 'confidentialite', 'marketing']
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_revoke_creates_separate_row():
"""Revoking later does NOT mutate the grant — both rows persist for audit trail."""
with app.app_context():
db.create_all()
try:
user = _make_user()
grant = ConsentLog(
user_id=user.id, consent_type='marketing', version='1.0',
granted=True, ip_address='192.0.2.1', user_agent='UA'
)
db.session.add(grant)
db.session.commit()
revoke = ConsentLog(
user_id=user.id, consent_type='marketing', version='1.0',
granted=False, ip_address='192.0.2.1', user_agent='UA',
revoked_at=datetime.utcnow()
)
db.session.add(revoke)
db.session.commit()
rows = ConsentLog.query.filter_by(consent_type='marketing').order_by(ConsentLog.id).all()
assert len(rows) == 2, "Grant and revoke must each have their own row (audit trail)"
assert rows[0].granted is True and rows[0].revoked_at is None
assert rows[1].granted is False and rows[1].revoked_at is not None
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_user_backref():
"""User.consent_logs backref returns the user's consent history."""
with app.app_context():
db.create_all()
try:
user = _make_user()
for ct in ('cgu', 'confidentialite'):
db.session.add(ConsentLog(
user_id=user.id, consent_type=ct, version='1.0',
granted=True, ip_address='192.0.2.1', user_agent='UA'
))
db.session.commit()
assert len(user.consent_logs) == 2
assert sorted([l.consent_type for l in user.consent_logs]) == ['cgu', 'confidentialite']
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_requires_ip_and_user_agent():
"""ip_address and user_agent are NOT NULL — required for Loi 25 traceability."""
from sqlalchemy.exc import IntegrityError
with app.app_context():
db.create_all()
try:
user = _make_user(username='erica', email='erica@example.com')
# Missing ip_address
log_no_ip = ConsentLog(
user_id=user.id, consent_type='cgu', version='1.0',
granted=True, user_agent='UA'
)
db.session.add(log_no_ip)
try:
db.session.commit()
raise AssertionError("Expected IntegrityError on missing ip_address")
except IntegrityError:
db.session.rollback()
# Missing user_agent
log_no_ua = ConsentLog(
user_id=user.id, consent_type='cgu', version='1.0',
granted=True, ip_address='192.0.2.1'
)
db.session.add(log_no_ua)
try:
db.session.commit()
raise AssertionError("Expected IntegrityError on missing user_agent")
except IntegrityError:
db.session.rollback()
finally:
db.session.rollback()
db.drop_all()
def test_user_has_new_b21_fields():
"""User model gained: totp_secret_encrypted, totp_enabled, webauthn_credentials, ordre_pro, cabinet, stripe_customer_id, subscription_status."""
with app.app_context():
db.create_all()
try:
user = _make_user()
user.totp_secret_encrypted = 'gAAAAABh-encrypted-fernet-token-placeholder'
user.totp_enabled = True
user.webauthn_credentials = [{'id': 'cred1', 'public_key': 'abc'}]
user.ordre_pro = 'barreau'
user.cabinet = 'Cabinet Pilote A'
user.stripe_customer_id = 'cus_TestCustomerId'
user.subscription_status = 'active'
db.session.commit()
fetched = User.query.filter_by(username='alice').first()
assert fetched.totp_secret_encrypted == 'gAAAAABh-encrypted-fernet-token-placeholder'
assert fetched.totp_enabled is True
assert fetched.webauthn_credentials == [{'id': 'cred1', 'public_key': 'abc'}]
assert fetched.ordre_pro == 'barreau'
assert fetched.cabinet == 'Cabinet Pilote A'
assert fetched.stripe_customer_id == 'cus_TestCustomerId'
assert fetched.subscription_status == 'active'
finally:
db.session.rollback()
db.drop_all()
def test_user_b21_fields_default_to_safe_values():
"""New User defaults: totp_enabled=False, others None."""
with app.app_context():
db.create_all()
try:
user = _make_user(username='bob', email='bob@example.com')
assert user.totp_enabled is False, "totp_enabled must default to False (no MFA bypass)"
assert user.totp_secret_encrypted is None
assert user.webauthn_credentials is None
assert user.stripe_customer_id is None
assert user.subscription_status is None
assert user.ordre_pro is None
assert user.cabinet is None
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_survives_user_deletion_with_null_user_id():
"""Loi 25 art. 28.1 right-to-erasure: deleting a User must NOT delete their
consent log rows. The user_id is set to NULL, the audit row survives.
"""
with app.app_context():
db.create_all()
try:
user = _make_user(username='claire', email='claire@example.com')
uid = user.id
log = ConsentLog(
user_id=uid, consent_type='cgu', version='2026-04-27',
granted=True, ip_address='192.0.2.1', user_agent='UA'
)
db.session.add(log)
db.session.commit()
log_id = log.id
db.session.delete(user)
db.session.commit()
# Audit row must survive
surviving = ConsentLog.query.get(log_id)
assert surviving is not None, "Consent log row must survive user deletion (Loi 25 audit trail)"
assert surviving.user_id is None, "user_id must be NULL after user deletion (data minimization)"
assert surviving.consent_type == 'cgu'
assert surviving.granted is True
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_rejects_invalid_consent_type():
"""Typos like 'comfidentialite' must be rejected at ORM level."""
with app.app_context():
db.create_all()
try:
user = _make_user(username='diane', email='diane@example.com')
try:
ConsentLog(
user_id=user.id, consent_type='comfidentialite', # typo
version='1.0', granted=True,
ip_address='192.0.2.1', user_agent='UA'
)
raise AssertionError("ValueError expected on invalid consent_type")
except ValueError as e:
assert 'Invalid consent_type' in str(e)
finally:
db.session.rollback()
db.drop_all()

View File

@@ -0,0 +1,404 @@
"""Tests for B-2.3 — DictIA-branded French transactional emails (verification + reset).
Covers:
- _get_email_template uses DictIA branding (no "Speakr" leaks).
- send_verification_email subject/body in French + DictIA.
- send_password_reset_email subject/body in French + DictIA.
- User display name (user.name) used in greetings, fallback to username.
- Anti-enumeration: /forgot-password gives the same flash for known/unknown emails.
- Cooldowns are enforced (60s) for resend-verification.
- SMTP_FROM_NAME defaults to "DictIA" when env var unset.
- send_verification_email returns False (no exception) when SMTP misconfigured.
- check_email.html refondu — extends marketing/base.html (DictIA brand tokens, no
legacy `var(--text-primary)` styles).
Note: pytest cannot collect this file on Windows native because src/init_db.py
imports `fcntl` (POSIX-only). Tests run in CI / Docker. A manual driver may be
provided alongside this file for Windows verification.
"""
import os
import sys
from datetime import datetime, timedelta
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
from src.app import app, db # noqa: E402
from src.models.user import User # noqa: E402
# --- Helpers ----------------------------------------------------------------
def _set_smtp_env():
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
os.environ['SMTP_HOST'] = 'smtp.test'
os.environ['SMTP_USERNAME'] = 'u'
os.environ['SMTP_PASSWORD'] = 'p'
def _clear_smtp_env():
for k in ('ENABLE_EMAIL_VERIFICATION', 'REQUIRE_EMAIL_VERIFICATION',
'SMTP_HOST', 'SMTP_USERNAME', 'SMTP_PASSWORD',
'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS'):
os.environ.pop(k, None)
def _make_user(username='jane', email='jane@x.qc.ca', name='Jane Bouchard'):
user = User(username=username, email=email, password='x' * 60,
name=name, email_verified=False)
db.session.add(user)
db.session.commit()
return user
# --- Tests ------------------------------------------------------------------
def test_email_template_uses_dictia_branding():
"""_get_email_template wraps content in DictIA-branded HTML scaffold (no Speakr)."""
with app.app_context():
from src.services.email import _get_email_template
html, text = _get_email_template(
content_html='<p>hello</p>',
content_text='hello',
subject='Test',
)
assert 'DictIA' in html, 'HTML must contain DictIA brand'
assert 'DictIA' in text, 'Plain text must contain DictIA brand'
assert 'Speakr' not in html, 'No "Speakr" string must remain in template'
assert 'Speakr' not in text, 'No "Speakr" string must remain in plain text'
# French footer copy + canonical contact email
assert 'info@dictia.ca' in html
assert 'Loi' in html and '25' in html, 'Tagline must mention Loi 25'
def test_email_template_header_uses_brand_gradient():
"""Header bg must use the official DictIA brand gradient (blue → cyan → fuchsia,
matches the official logo). The legacy #0062ff/#00bdd8/#00c896 palette must be gone."""
with app.app_context():
from src.services.email import _get_email_template
html, _ = _get_email_template('x', 'x', 'Test')
# Legacy palette must be removed
assert '#0062ff' not in html, 'Legacy header color #0062ff must be removed'
assert '#00bdd8' not in html, 'Legacy mid color #00bdd8 must be removed'
assert '#00c896' not in html, 'Legacy end color #00c896 must be removed'
# New official-logo palette must be present
assert '#2563eb' in html, 'DictIA brand blue (#2563eb) must be present'
assert '#06b6d4' in html, 'DictIA brand cyan (#06b6d4) must be present'
assert '#c026d3' in html, 'DictIA brand fuchsia (#c026d3) must be present'
def test_verification_email_subject_is_french_with_dictia():
"""Subject = 'Vérifiez votre courriel — DictIA'."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = _make_user()
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_to, subject, _html, _text = args
assert subject == 'Vérifiez votre courriel — DictIA'
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_verification_email_body_uses_user_name_when_set():
"""Greeting uses user.name (display name) when populated."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = _make_user(username='jane123', email='jane@x.qc.ca',
name='Jane Bouchard')
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_to, _subject, html, text = args
assert 'Bonjour Jane Bouchard' in html
assert 'Bonjour Jane Bouchard' in text
assert 'Bonjour jane123' not in html
# French body copy
assert 'Vérifier mon courriel' in html
assert 'Bienvenue chez DictIA' in html or "Bienvenue chez DictIA" in text
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_verification_email_body_falls_back_to_username():
"""When user.name is None, greeting uses user.username."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = _make_user(username='bob42', email='bob@x.qc.ca', name=None)
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_to, _subject, html, _text = args
assert 'Bonjour bob42' in html
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_password_reset_subject_french():
"""Subject = 'Réinitialiser votre mot de passe — DictIA'."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = _make_user(username='carol', email='carol@x.qc.ca',
name='Carol Tremblay')
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_password_reset_email
send_password_reset_email(user)
args, _ = mock_send.call_args
_to, subject, html, _text = args
assert subject == 'Réinitialiser votre mot de passe — DictIA'
assert 'Bonjour Carol Tremblay' in html
assert 'Réinitialiser mon mot de passe' in html
assert 'Speakr' not in html
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_send_verification_returns_false_when_smtp_not_configured():
"""No exception, just False — keeps registration robust."""
with app.app_context():
_clear_smtp_env()
# Verification enabled but SMTP missing
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
db.create_all()
try:
user = _make_user()
from src.services.email import send_verification_email
assert send_verification_email(user) is False
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_smtp_from_name_defaults_to_dictia():
"""When SMTP_FROM_NAME is unset, get_email_config() returns 'DictIA'."""
_clear_smtp_env()
from src.services.email import get_email_config
cfg = get_email_config()
assert cfg['from_name'] == 'DictIA', (
'Default SMTP_FROM_NAME must be "DictIA", not "Speakr"'
)
def test_forgot_password_returns_generic_message_for_unknown_email():
"""Anti-enumeration: unknown email gets the same generic message."""
with app.app_context():
_set_smtp_env()
app.config['WTF_CSRF_ENABLED'] = False
db.create_all()
try:
client = app.test_client()
# No user exists with this email
with patch('src.services.email._send_email', return_value=True):
resp = client.post('/forgot-password',
data={'email': 'nobody@nope.qc.ca'})
# Page should render the generic message in body
body = resp.data.decode('utf-8')
assert 'Si un compte' in body or 'lien de réinitialisation' in body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_forgot_password_returns_same_message_for_known_email():
"""Anti-enumeration: known email gets the SAME generic message."""
with app.app_context():
_set_smtp_env()
app.config['WTF_CSRF_ENABLED'] = False
db.create_all()
try:
user = _make_user(username='dora', email='dora@x.qc.ca')
client = app.test_client()
with patch('src.services.email._send_email', return_value=True):
resp = client.post('/forgot-password',
data={'email': user.email})
body = resp.data.decode('utf-8')
assert 'Si un compte' in body or 'lien de réinitialisation' in body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_check_email_template_extends_marketing_base():
"""check_email.html uses DictIA marketing layout, no legacy Vue styles."""
with app.test_request_context('/'):
_set_smtp_env()
app.config['WTF_CSRF_ENABLED'] = False
db.create_all()
try:
from flask import render_template
html = render_template(
'auth/check_email.html',
title='Vérifiez votre courriel',
email='alice@x.qc.ca',
action='verification',
show_resend=True,
)
# New marketing layout markers
assert 'marketing.css' in html or 'grad-text' in html or 'brand-navy' in html
# Legacy Vue/Tailwind v3 design tokens MUST be gone
assert 'var(--text-primary)' not in html
assert 'var(--bg-secondary)' not in html
# French + brand
assert 'DictIA' in html
assert 'alice@x.qc.ca' in html
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_verification_email_falls_back_when_name_is_whitespace():
"""Empty/whitespace name must NOT produce 'Bonjour ,' — falls back to username."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = User(username='claire42', email='claire@example.qc.ca',
password='x' * 60, name=' ', email_verified=False)
db.session.add(user)
db.session.commit()
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_, _, html_body, text_body = args
assert 'Bonjour ,' not in html_body
assert 'Bonjour claire42' in html_body
assert 'Bonjour claire42' in text_body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_verification_email_handles_unicode_name():
"""Accented French names must round-trip through email without mojibake."""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
user = User(username='francois', email='francois@example.qc.ca',
password='x' * 60, name='François Mélanie',
email_verified=False)
db.session.add(user)
db.session.commit()
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_, _, html_body, text_body = args
assert 'Bonjour François Mélanie' in html_body
assert 'Bonjour François Mélanie' in text_body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_verification_email_escapes_html_in_user_name():
"""user.name with HTML payload must be escaped in HTML body, raw in text body.
Regression test for C1 (stored XSS). A signup with name='<img onerror=...>'
persists the payload — without escape it executes when the verification
email renders.
"""
with app.test_request_context('/'):
_set_smtp_env()
db.create_all()
try:
payload = '<img src=x onerror=alert(1)>'
user = User(username='attacker', email='attacker@x.ca',
password='x' * 60, name=payload, email_verified=False)
db.session.add(user)
db.session.commit()
with patch('src.services.email._send_email', return_value=True) as mock_send:
from src.services.email import send_verification_email
send_verification_email(user)
args, _ = mock_send.call_args
_, _, html_body, text_body = args
# HTML body MUST escape the payload
assert payload not in html_body, \
'Raw HTML payload leaked into HTML email body!'
assert '&lt;img src=x onerror=alert(1)&gt;' in html_body
# Text body keeps the raw string (it's plaintext, no XSS surface)
assert payload in text_body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_check_email_template_escapes_email_in_response():
"""email value rendered into check_email.html must be HTML-escaped.
Regression test for C2 (reflected XSS). Posting a script payload to
/forgot-password reflected it unescaped via concat-then-safe pattern.
"""
with app.app_context():
app.config['WTF_CSRF_ENABLED'] = False
_set_smtp_env()
db.create_all()
try:
client = app.test_client()
payload = '<script>alert(1)</script>'
resp = client.post('/forgot-password', data={'email': payload})
assert resp.status_code == 200
body = resp.data.decode('utf-8')
assert payload not in body, \
'Raw <script> payload leaked into rendered HTML!'
assert '&lt;script&gt;alert(1)&lt;/script&gt;' in body
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()
def test_resend_verification_rate_limited_per_user():
"""can_resend_verification returns (False, remaining) within the 60s cooldown."""
with app.app_context():
_set_smtp_env()
db.create_all()
try:
user = _make_user(username='eric', email='eric@x.qc.ca')
from src.services.email import can_resend_verification
# Simulate recent send
user.email_verification_sent_at = datetime.utcnow()
db.session.commit()
can, remaining = can_resend_verification(user)
assert can is False
assert remaining is not None and remaining > 0
# Simulate older send (>60s) — should now allow
user.email_verification_sent_at = datetime.utcnow() - timedelta(seconds=120)
db.session.commit()
can, remaining = can_resend_verification(user)
assert can is True
assert remaining is None
finally:
db.session.rollback()
db.drop_all()
_clear_smtp_env()

331
tests/test_legal_pages.py Normal file
View File

@@ -0,0 +1,331 @@
"""Tests for the 6 legal pages blueprint (Task B-2.9).
All 6 markdown-rendered pages plus the index must:
- Return HTTP 200 with DictIA branding
- Be publicly indexable (no X-Robots-Tag noindex header — Loi 25 transparency)
- Share the same _layout.html structure (extends marketing/base.html)
- Be marked DRAFT pending legal review by Allison Rioux
- The privacy policy must satisfy the 12 mandatory Loi 25 sections
- LEGAL_VERSION constant must match SIGNUP_LEGAL_VERSION used by the signup route
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
from src.app import app, db # noqa: E402
VALID_PAGES = ('conditions', 'confidentialite', 'cookies', 'remboursement', 'accessibilite', 'mentions')
def test_legal_index_returns_200_with_all_6_pages_listed():
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/')
assert resp.status_code == 200
body = resp.data.decode('utf-8')
for page in VALID_PAGES:
assert f'/legal/{page}' in body
assert 'Documents légaux' in body
finally:
db.session.rollback(); db.drop_all()
def test_each_legal_page_returns_200_with_dictia_branding():
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
assert resp.status_code == 200, f'/legal/{page} returned {resp.status_code}'
body = resp.data.decode('utf-8')
assert 'DictIA' in body
assert 'rprp@dictia.ca' in body or 'info@dictia.ca' in body
finally:
db.session.rollback(); db.drop_all()
def test_unknown_legal_page_returns_404():
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/unknown-page')
assert resp.status_code == 404
finally:
db.session.rollback(); db.drop_all()
def test_confidentialite_has_all_12_loi25_sections():
"""LPRPSP (Loi 25) requires 12 mandatory sections in privacy policy."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/confidentialite')
assert resp.status_code == 200
body = resp.data.decode('utf-8').lower()
required_topics = [
'identité du responsable',
'rprp', # responsable de la protection
'renseignements personnels collectés',
'finalités',
'base légale', # base légale et consentement
'destinataires', # destinataires et sous-traitants
'transferts hors québec', # canonical PDC §11 wording (no hyphen, plural)
'durée de conservation',
'droits', # droits de l'utilisateur
'plainte', # procédure de plainte CAI
'cookies', # cookies et traceurs
'biométriques', # données biométriques (LCCJTI 44-45) — ajout 2026-04-27
'décisions automatisées', # ajout 2026-04-27 (PDC §10)
'date de mise à jour',
]
for topic in required_topics:
assert topic in body, f'Missing Loi 25 mandatory section: {topic!r}'
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_use_layout_template_with_shared_layout():
"""All 6 pages should share the same _layout.html structure."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
assert 'Document légal DictIA' in body, f'_layout.html header missing on /legal/{page}'
assert 'Index des documents légaux' in body, f'_layout.html footer link missing on /legal/{page}'
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_publicly_indexable():
"""legal.* endpoints must NOT have X-Robots-Tag noindex header (Loi 25 transparency)."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
tag = resp.headers.get('X-Robots-Tag', '')
assert 'noindex' not in tag, f'/legal/{page} has noindex header: {tag!r}'
# Also test the index
resp = client.get('/legal/')
tag = resp.headers.get('X-Robots-Tag', '')
assert 'noindex' not in tag
finally:
db.session.rollback(); db.drop_all()
def test_legal_version_constant_matches_signup():
"""LEGAL_VERSION in src/legal must equal SIGNUP_LEGAL_VERSION used by signup route."""
from src.legal import LEGAL_VERSION
from src.api.auth import SIGNUP_LEGAL_VERSION
assert LEGAL_VERSION == SIGNUP_LEGAL_VERSION, (
f'LEGAL_VERSION ({LEGAL_VERSION!r}) must match SIGNUP_LEGAL_VERSION ({SIGNUP_LEGAL_VERSION!r})'
)
def test_legal_pages_extend_marketing_base_template():
"""All 6 pages extend marketing/base.html (verify by looking for header markers)."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
# marketing/base.html has the glassmorphism header at the top
assert 'class="fixed top-0' in body, f'/legal/{page} missing marketing/base.html header'
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_have_loi25_draft_callout():
"""All 6 pages should be marked DRAFT pending legal review by Allison."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8').lower()
assert 'draft' in body or 'allison rioux' in body, (
f'/legal/{page} missing draft+legal-review callout'
)
finally:
db.session.rollback(); db.drop_all()
# ---------------------------------------------------------------------------
# B-2.10 — UX upgrade tests : AGPL external link, skip link, breadcrumb,
# landmarks, prev/next navigation, sticky TOC.
# ---------------------------------------------------------------------------
def test_legal_index_includes_agpl_external_link():
"""The /legal/ index must surface the AGPL source code as an external link."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/')
assert resp.status_code == 200
body = resp.data.decode('utf-8')
assert 'https://gitea.dictia.ca' in body
assert 'target="_blank"' in body
assert 'rel="noopener noreferrer"' in body
assert 'AGPL' in body
finally:
db.session.rollback(); db.drop_all()
def test_legal_index_lists_5_internal_pages_plus_agpl():
"""Index must show internal pages + AGPL external card (count >=6)."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/')
body = resp.data.decode('utf-8')
for slug in ('conditions', 'confidentialite', 'cookies',
'remboursement', 'accessibilite'):
assert f'/legal/{slug}' in body, f'Missing internal card: {slug}'
# External AGPL link
assert 'gitea.dictia.ca' in body
# Count cards via the legal-card class
assert body.count('legal-card') >= 6, (
f'Expected at least 6 legal-card occurrences, found {body.count("legal-card")}'
)
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_have_skip_link():
"""Every legal page must include a WCAG skip link to #main-content."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
assert 'href="#main-content"' in body, (
f'/legal/{page} missing skip link to #main-content'
)
assert 'Aller au contenu principal' in body, (
f'/legal/{page} missing French skip link label'
)
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_have_prev_next_navigation():
"""Each legal page (except first/last) must have prev OR next link to neighbours."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
# The wrapping nav must always be present (rel="prev" OR rel="next").
assert 'rel="prev"' in body or 'rel="next"' in body, (
f'/legal/{page} has neither prev nor next neighbour link'
)
finally:
db.session.rollback(); db.drop_all()
def test_legal_first_page_no_prev_link():
"""The first page (conditions) must not expose a 'prev' link."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/conditions')
body = resp.data.decode('utf-8')
assert 'rel="prev"' not in body, "conditions page should not have a prev link"
assert 'rel="next"' in body, "conditions page should have a next link"
finally:
db.session.rollback(); db.drop_all()
def test_legal_last_page_no_next_link():
"""The last page (mentions) must not expose a 'next' link."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
resp = client.get('/legal/mentions')
body = resp.data.decode('utf-8')
assert 'rel="next"' not in body, "mentions page should not have a next link"
assert 'rel="prev"' in body, "mentions page should have a prev link"
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_aside_toc_landmark():
"""Every legal page must expose an <aside aria-label='Table des matières'> landmark."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
assert 'aria-label="Table des matières"' in body, (
f'/legal/{page} missing TOC aside landmark'
)
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_main_landmark():
"""Every legal page must wrap its article in role='main' with id='main-content'."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
assert 'id="main-content"' in body, (
f'/legal/{page} missing id="main-content"'
)
assert 'role="main"' in body, (
f'/legal/{page} missing role="main"'
)
finally:
db.session.rollback(); db.drop_all()
def test_legal_pages_breadcrumb_present():
"""Every legal page must include a Fil d'Ariane breadcrumb."""
with app.app_context():
db.create_all()
try:
client = app.test_client()
for page in VALID_PAGES:
resp = client.get(f'/legal/{page}')
body = resp.data.decode('utf-8')
assert "aria-label=\"Fil d'Ariane\"" in body, (
f'/legal/{page} missing breadcrumb landmark'
)
assert 'aria-current="page"' in body, (
f'/legal/{page} missing aria-current="page" on breadcrumb'
)
# Breadcrumb chain should reference Accueil and Documents légaux
assert 'Accueil' in body and 'Documents légaux' in body, (
f'/legal/{page} breadcrumb chain incomplete'
)
finally:
db.session.rollback(); db.drop_all()

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More