Compare commits
75 Commits
dictia
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224e51cc81 | ||
|
|
a14bcb9a1a | ||
|
|
8a7650f9fa | ||
|
|
323f0c81c4 | ||
|
|
7d3348c3fd | ||
|
|
d6ff71640a | ||
|
|
199b315bc2 | ||
|
|
5edaddd788 | ||
|
|
7aaedf2cdf | ||
|
|
03f6e56f04 | ||
|
|
e06cba2123 | ||
|
|
1c4cafaf69 | ||
|
|
e8c7e5cd43 | ||
|
|
575db5e342 | ||
|
|
34d40162b3 | ||
|
|
680df39089 | ||
|
|
529bd2263b | ||
|
|
69baa1be2f | ||
|
|
e49652d85d | ||
|
|
aad37f8566 | ||
|
|
3e56736fa7 | ||
|
|
48d65c2ab9 | ||
|
|
8d50d8ee01 | ||
|
|
f83fdfcd68 | ||
|
|
0b91294c45 | ||
|
|
48ff4e70e6 | ||
|
|
924d127ab4 | ||
|
|
dc4ac9754b | ||
|
|
e1e31b51fd | ||
|
|
55569366f4 | ||
|
|
64738bfd1f | ||
|
|
f1a5ad565f | ||
|
|
b8fa321edd | ||
|
|
aa269c5bc0 | ||
|
|
3a41bb482d | ||
|
|
0513e67838 | ||
|
|
dd270bca9e | ||
|
|
37639a7d09 | ||
|
|
3b324ad0b9 | ||
|
|
d2fc1f03ed | ||
|
|
8792ffb8a4 | ||
|
|
48d2abfa74 | ||
|
|
d45c9c9349 | ||
|
|
3646a5e64d | ||
|
|
202e1a08d9 | ||
|
|
d471626183 | ||
|
|
2b3eeb98e0 | ||
|
|
824ea638de | ||
|
|
31fada46d4 | ||
|
|
0d69fcd034 | ||
|
|
7d67b64ddc | ||
|
|
0ae4053faa | ||
|
|
b87f35ea4a | ||
|
|
775075d1ea | ||
|
|
7c6c6fd433 | ||
|
|
3c471a72d1 | ||
|
|
54168e443b | ||
|
|
2a7e142b03 | ||
|
|
b24a0f064d | ||
|
|
03af2a516d | ||
|
|
89e2fd29d1 | ||
|
|
49bf94576c | ||
|
|
08318a946f | ||
|
|
af2953995c | ||
|
|
1071e56173 | ||
|
|
55ae09431d | ||
|
|
e01523125e | ||
|
|
accd9ebf36 | ||
|
|
b1a84135e2 | ||
|
|
2e2f343520 | ||
|
|
571890e692 | ||
|
|
191711c4d9 | ||
|
|
3ca542fe40 | ||
|
|
31948aec01 | ||
|
|
b27b3c1d44 |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
15
Dockerfile
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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).
|
||||||
@@ -1 +1,2 @@
|
|||||||
scipy<1.15
|
scipy<1.15
|
||||||
|
cryptography==47.0.0
|
||||||
|
|||||||
2457
package-lock.json
generated
Normal file
20
package.json
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
...(process.env.NODE_ENV === 'production' ? { cssnano: { preset: 'default' } } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
1058
src/api/auth.py
@@ -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,
|
||||||
|
|||||||
55
src/app.py
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||||
|
)
|
||||||
139
src/billing/stripe_client.py
Normal 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
@@ -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})
|
||||||
@@ -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
@@ -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
|
||||||
66
src/legal/content/accessibilite.md
Normal 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> — 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.
|
||||||
187
src/legal/content/conditions.md
Normal 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> — 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 %) et Jean-David Lévesque-Rioux (50 %)
|
||||||
|
- **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 Go | Poste de travail du Client | Aucun |
|
||||||
|
| DictIA 16 | Version locale — GPU 16 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 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 % sur le tarif annuel).
|
||||||
|
|
||||||
|
Les taxes applicables (TPS 5 % et TVQ 9,975 %) 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 : 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) : 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 : 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).
|
||||||
230
src/legal/content/confidentialite.md
Normal 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> — 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 %) et Jean-David Lévesque-Rioux (50 %)
|
||||||
|
- **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 : 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 : 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).
|
||||||
59
src/legal/content/cookies.md
Normal 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> — 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 → Paramètres → 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.
|
||||||
73
src/legal/content/mentions.md
Normal 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> — 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 %), Chef de la direction (CEO), et Jean-David Lévesque-Rioux (50 %), Chef des technologies (CTO).
|
||||||
|
|
||||||
|
## 2. Adresse du siège social
|
||||||
|
|
||||||
|
77 chemin de la Seigneurie
|
||||||
|
Inverness QC G0S 1K0
|
||||||
|
Canada
|
||||||
|
District judiciaire : 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 : 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 : 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.
|
||||||
59
src/legal/content/remboursement.md
Normal 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> — 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 → 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
@@ -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
@@ -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
@@ -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 LOCAL — RTX 5070 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 25, l\'audit trail (art. 3.5 LPRPSP), le registre des consentements (art. 14) et l\'EFVP (art. 3.3) sont fournis par défaut.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Quels formats audio/vidéo sont supportés?',
|
||||||
|
'a': 'DictIA accepte tous les formats courants : 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 heure d\'audio?',
|
||||||
|
'a': 'Environ 2 minutes sur GPU. C\'est 99 % plus rapide que la transcription manuelle, qui prend typiquement 4 à 6 heures pour 1 heure d\'audio. La précision typique observée sur nos jeux de tests internes dépasse 95 % en français canadien. Méthodologie complète disponible sur demande : <a href="mailto:info@dictia.ca" class="grad-text underline">info@dictia.ca</a>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'La transcription est-elle vraiment confidentielle?',
|
||||||
|
'a': 'Avec DictIA LOCAL, vos données ne quittent jamais votre bureau — le traitement est 100 % 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 25 (art. 44-45) exige un consentement explicite pour transmettre des données biométriques (voix) hors du Québec. Depuis septembre 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 25 depuis septembre 2023.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Que dit le Barreau du Québec sur l\'IA?',
|
||||||
|
'a': 'En octobre 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 2026. En attendant, DictIA exporte nativement en DOCX, compatible avec tous les logiciels de gestion de dossiers. L\'importation manuelle prend moins de 30 secondes par transcription. Intégrations natives disponibles : Word, Outlook, Teams, Notion, Obsidian, Zapier, Make, n8n.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Ai-je besoin de connaissances techniques?',
|
||||||
|
'a': 'Non. DictIA est une solution clé en main : 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 jours (art. 23 LPRPSP).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'DictIA est-il open source?',
|
||||||
|
'a': 'Oui. Le code source est sous licence AGPL v3 — transparence totale. La stack complète (WhisperX, pyannote, Mistral, Ollama, FastAPI, PostgreSQL) est 100 % 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 : 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')
|
||||||
@@ -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
@@ -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}>"
|
||||||
55
src/models/subscription.py
Normal 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}>'
|
||||||
@@ -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}')"
|
||||||
|
|
||||||
|
|||||||
28
src/models/webhook_event.py
Normal 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}>'
|
||||||
@@ -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 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;">
|
||||||
© {datetime.utcnow().year} Speakr · AI-Powered Audio Transcription
|
© {datetime.utcnow().year} DictIA — Transcription IA conforme Loi 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 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 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>
|
|
||||||
</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>
|
</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 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
@@ -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
65
static/css/tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
BIN
static/fonts/Inter-Variable.woff2
Normal file
BIN
static/fonts/JetBrainsMono-Variable.woff2
Normal file
BIN
static/images/dictia-logo-128.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
static/images/dictia-logo-fullres.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
static/images/dictia-logo-nom-fullres.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
static/images/dictia-logo-nom.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
static/images/dictia-logo-nom.svg
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
static/images/dictia-logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
1
static/images/dictia-logo.svg
Normal file
|
After Width: | Height: | Size: 258 KiB |
11
static/images/favicon.svg
Normal 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 |
BIN
static/images/og/og-default.png
Normal file
|
After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 28 KiB |
5
static/js/alpine.min.js
vendored
Normal file
22
static/js/roi_calculator.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
178
static/js/webauthn-client.js
Normal 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);
|
||||||
@@ -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: /
|
|
||||||
|
|||||||
@@ -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">✉</div>
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
|
||||||
document.documentElement.classList.add('dark');
|
{% if action == 'password_reset' %}Vérifiez votre courriel
|
||||||
} else {
|
{% elif action == 'verification_required' %}Vérification requise
|
||||||
document.documentElement.classList.remove('dark');
|
{% elif action == 'magic_link' %}Lien de connexion envoyé
|
||||||
}
|
{% else %}Confirmez votre courriel{% endif %}
|
||||||
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>
|
</h1>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
<p class="text-sm text-brand-navy/70 mb-6">
|
||||||
<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' %}
|
||||||
|
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 heure.
|
||||||
|
{% elif action == 'verification_required' %}
|
||||||
|
Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous.
|
||||||
|
{% 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 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 heures.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% 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 %}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="text-center">
|
{% if show_resend and action != 'password_reset' %}
|
||||||
<div class="mb-6">
|
<form method="POST" action="{{ url_for('auth.resend_verification') }}" class="mb-4">
|
||||||
<div class="w-20 h-20 mx-auto bg-[var(--bg-info-light)] rounded-full flex items-center justify-center">
|
|
||||||
<i class="fas fa-envelope text-[var(--text-info-strong)] text-3xl"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if action == 'verification' %}
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
|
|
||||||
<p class="text-[var(--text-secondary)] mb-2">We've sent a verification link to:</p>
|
|
||||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
|
||||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
|
||||||
Click the link in the email to verify your account. The link will expire in 24 hours.
|
|
||||||
</p>
|
|
||||||
{% elif action == 'verification_required' %}
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verification Required</h2>
|
|
||||||
<p class="text-[var(--text-secondary)] mb-2">Please verify your email address:</p>
|
|
||||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
|
||||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
|
||||||
Check your inbox for a verification email. If you haven't received it, you can request a new one.
|
|
||||||
</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') %}
|
|
||||||
<div class="mb-6">
|
|
||||||
<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="text-[var(--text-accent)] hover:underline text-sm">
|
<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">
|
||||||
<i class="fas fa-redo mr-1"></i> Resend verification email
|
Renvoyer le lien de vérification
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% 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 ? 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>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <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">← Retour à la connexion</a>
|
||||||
</footer>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -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 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">
|
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Forgot Password</h2>
|
|
||||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
|
||||||
Enter your email address and we'll send you a link to reset your password.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% 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 %}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('auth.forgot_password') }}">
|
<form method="POST" action="{{ url_for('auth.forgot_password') }}" class="space-y-4" novalidate>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-6">
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Email Address</label>
|
<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" required
|
<input type="email" id="email" name="email" autocomplete="email" 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 email address">
|
placeholder="vous@cabinet.qc.ca">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col space-y-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">
|
||||||
<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)]">
|
Recevoir un lien de réinitialisation
|
||||||
<i class="fas fa-paper-plane mr-2"></i> Send Reset Link
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
|
||||||
<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>
|
</form>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <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">← Retour à la connexion</a>
|
||||||
</footer>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
50
templates/auth/magic_link_request.html
Normal 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 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 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">← Retour à la connexion</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
80
templates/auth/oauth_finish_signup.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'marketing/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Finaliser votre inscription DictIA{% endblock %}
|
||||||
|
{% block description %}Finalisez votre inscription DictIA — consentements Loi 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 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 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 ?
|
||||||
|
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Inscription manuelle</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
79
templates/auth/passkey_setup.html
Normal 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 Loi 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">← 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 %}
|
||||||
@@ -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">
|
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Reset Password</h2>
|
|
||||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
|
||||||
Enter your new password below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% 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 %}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}">
|
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}" class="space-y-4" novalidate>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<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="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" required
|
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required aria-required="true" aria-describedby="password-help"
|
||||||
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>
|
<p id="password-help" class="text-xs text-brand-navy/70 mt-1">{{ "8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial." | safe }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div>
|
||||||
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Confirm 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="confirm_password" name="confirm_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="Confirm your new password">
|
placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col space-y-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">
|
||||||
<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)]">
|
Définir mon nouveau mot de passe
|
||||||
<i class="fas fa-key mr-2"></i> Reset Password
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
|
||||||
<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>
|
</form>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <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">← Retour à la connexion</a>
|
||||||
</footer>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
93
templates/auth/totp_setup.html
Normal 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 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 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 ?<br>Saisissez la clé manuellement :</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">← Annuler et retourner au compte</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
111
templates/auth/totp_verify.html
Normal 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 ? 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 : 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 %}
|
||||||
@@ -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">✓</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 25.
|
||||||
<div class="mb-6">
|
|
||||||
<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>
|
|
||||||
<p class="text-[var(--text-secondary)] mb-6">
|
|
||||||
Your email address has been successfully verified. You can now log in to your account.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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">
|
<a href="{{ url_for('auth.login') }}"
|
||||||
<i class="fas fa-sign-in-alt mr-2"></i> Continue to Login
|
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">
|
||||||
|
Se connecter
|
||||||
</a>
|
</a>
|
||||||
</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="text-xs text-brand-navy/70 mt-6">
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
Une question ? Écrivez-nous à
|
||||||
</footer>
|
<a href="mailto:info@dictia.ca" class="grad-text font-semibold">info@dictia.ca</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
49
templates/billing/cancel.html
Normal 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é ?</h3>
|
||||||
|
<p class="text-sm text-brand-navy/80 leading-relaxed mb-4">
|
||||||
|
Si vous avez une question sur les forfaits, la conformité Loi 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) 996-8471</a>. Réponse sous 2 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 %}
|
||||||
89
templates/billing/success.html
Normal 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 ! 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 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 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 Cloud</strong> : accès immédiat depuis votre tableau de bord.<br>
|
||||||
|
Pour les forfaits <strong>DictIA 8</strong> et <strong>DictIA 16</strong> (on-premise) : notre équipe vous contactera sous 1 jour ouvrable pour planifier l'installation (~2 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 : {{ 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 ?
|
||||||
|
</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) 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 %}
|
||||||
381
templates/legal/_layout.html
Normal 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 : {{ legal_version }}</span>
|
||||||
|
<span class="text-brand-navy/40" aria-hidden="true">·</span>
|
||||||
|
<span>RPRP : <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
@@ -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 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 {{ 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 :
|
||||||
|
<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 sous-processeurs</strong> : OVH (Beauharnois, 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 %}
|
||||||
@@ -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 25." | safe }}</p>
|
||||||
|
|
||||||
// Apply dark mode
|
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply color scheme
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
|
|
||||||
// Remove all other theme classes
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the correct theme class
|
|
||||||
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">
|
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Connexion</h2>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% 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)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% if sso_enabled %}
|
{# OAuth providers (Microsoft 365 + Google) — rendered only if env-enabled #}
|
||||||
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}">
|
{% if oauth_microsoft_enabled or oauth_google_enabled or sso_enabled %}
|
||||||
<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">
|
<div class="space-y-3 mb-6" aria-label="Connexion fédérée">
|
||||||
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }}
|
{% 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>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if oauth_google_enabled %}
|
||||||
|
<a href="{{ url_for('auth.oauth_provider_login', provider='google') }}"
|
||||||
|
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 Google "G" logo #}
|
||||||
|
<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 %}
|
||||||
|
|
||||||
|
{% if sso_enabled %}
|
||||||
|
<a href="{{ url_for('auth.sso_login') }}"
|
||||||
|
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"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Se connecter avec {{ sso_provider_name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not password_login_disabled %}
|
{% if not password_login_disabled %}
|
||||||
<div class="flex items-center text-xs text-[var(--text-muted)]">
|
<div class="flex items-center text-xs uppercase tracking-wide text-brand-navy/60 my-3">
|
||||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
<span class="flex-grow border-t border-brand-border"></span>
|
||||||
<span class="mx-3 uppercase tracking-wide">ou</span>
|
<span class="mx-3">ou</span>
|
||||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
<span class="flex-grow border-t border-brand-border"></span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if password_login_disabled %}
|
{% if not password_login_disabled %}
|
||||||
<div class="mt-4 text-center">
|
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-4" novalidate>
|
||||||
<button type="button" onclick="document.getElementById('admin-login-form').classList.toggle('hidden')" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
|
||||||
<i class="fas fa-lock mr-1"></i> Connexion administrateur
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="admin-login-form" method="POST" action="{{ url_for('auth.login') }}" class="hidden mt-4">
|
|
||||||
{{ form.hidden_tag() }}
|
{{ 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>
|
||||||
|
<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>
|
||||||
|
{{ 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'}) }}
|
||||||
|
{% if form.email.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.email.errors[0] }}</p>{% endif %}
|
||||||
</div>
|
</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>
|
||||||
|
<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>
|
||||||
|
{{ 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'}) }}
|
||||||
|
{% if form.password.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.password.errors[0] }}</p>{% endif %}
|
||||||
</div>
|
</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)]">
|
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<label for="remember" class="flex items-center gap-2 text-brand-navy/90 cursor-pointer">
|
||||||
|
{{ form.remember(id='remember', **{'class':'rounded-none focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
|
||||||
|
<span>Se souvenir de moi</span>
|
||||||
|
</label>
|
||||||
|
<a href="{{ url_for('auth.forgot_password') }}" class="grad-text font-semibold hover:underline">Mot de passe oublié ?</a>
|
||||||
|
</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">
|
||||||
Se connecter
|
Se connecter
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<p class="text-center text-sm mt-4">
|
||||||
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<a href="{{ url_for('auth.magic_link_request') }}" class="grad-text font-semibold hover:underline">
|
||||||
{% if form.email.errors %}
|
{{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }}
|
||||||
{{ 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)]") }}
|
</a>
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
</p>
|
||||||
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
Pas encore de compte ?
|
||||||
{% if form.password.errors %}
|
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Créer un compte</a>
|
||||||
{{ 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)]") }}
|
</p>
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
|
||||||
{% for error in form.password.errors %}
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</section>
|
||||||
{{ 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)]") }}
|
{% endblock %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
{{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }}
|
|
||||||
{{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }}
|
|
||||||
</div>
|
|
||||||
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-[var(--text-accent)] hover:underline">Mot de passe oublié ?</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
|
||||||
{{ 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)]") }}
|
|
||||||
|
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
|
||||||
<span>Pas encore de compte ?</span>
|
|
||||||
<a href="{{ url_for('auth.register') }}" class="font-medium text-[var(--text-accent)] hover:underline">S'inscrire</a>
|
|
||||||
</div>
|
|
||||||
</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>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <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>
|
|
||||||
|
|||||||
17
templates/macros/bento.html
Normal 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 %}
|
||||||
30
templates/macros/button.html
Normal 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 -%}
|
||||||
116
templates/macros/pricing_card.html
Normal 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 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 h audio/mois'
|
||||||
|
capacity_storage : Capacity chip (storage) — e.g. '100 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 998 $`.
|
||||||
|
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 998' (OQLF thousands separator) #}
|
||||||
|
{%- macro fmt_price(n) -%}
|
||||||
|
{%- set s = n | string -%}
|
||||||
|
{%- if s | length > 3 -%}{{ s[:-3] }} {{ 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) }} $</div>
|
||||||
|
<div class="text-xs text-brand-navy/70 mt-2">An 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) }} $/an</strong> dès An 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) }} $<span class="text-base text-brand-navy/60 font-bold"> / mois</span></div>
|
||||||
|
<div class="text-xs text-brand-navy/70 mt-2">+ {{ fmt_price(setup) }} $ onboarding (unique)</div>
|
||||||
|
{% else %}
|
||||||
|
{# Cloud Basic / Essentiel — monthly only #}
|
||||||
|
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }} $<span class="text-base text-brand-navy/60 font-bold"> / 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 -%}
|
||||||
69
templates/marketing/_footer.html
Normal 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 25, conçue au Québec.</p>
|
||||||
|
<address class="not-italic text-xs text-white/70 mt-4 leading-relaxed">
|
||||||
|
77 ch. de la Seigneurie<br>
|
||||||
|
Inverness QC G0S 1K0<br>
|
||||||
|
<a href="tel:+15819968471" class="hover:text-white">(581) 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 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>
|
||||||
198
templates/marketing/_partials/_pricing_tiers.html
Normal 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 %+ précision · 99+ langues',
|
||||||
|
'Diarisation pyannote (qui parle)',
|
||||||
|
'Résumés IA + Points d’action (Mistral Nemo 12B)',
|
||||||
|
'Exports SRT, VTT, TXT, JSON, DOCX',
|
||||||
|
'Hébergement OVH Beauharnois (QC)',
|
||||||
|
'Conforme Loi 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 h audio/mois',
|
||||||
|
capacity_storage='100 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 h audio/mois',
|
||||||
|
capacity_storage='200 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 h audio/mois',
|
||||||
|
capacity_storage='500 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’abonnement matériel'),
|
||||||
|
('Traitement 100 % local', 'aucun transit réseau, fonctionne hors-ligne'),
|
||||||
|
('Assemblé et configuré au Québec par InnovA AI', 'support local inclus'),
|
||||||
|
('On vient l’installer chez vous', 'formation incluse, opérationnel le jour 1'),
|
||||||
|
('Achat direct sans appel d’offres si < 34 700 $', 'DictIA LOCAL s’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 998 $ An 1 · 500 $/an dès An 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 Ti 16 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 PRO ?</h3>
|
||||||
|
<p class="text-sm text-white/75 leading-relaxed">
|
||||||
|
> 660 h audio/mois · > 500 Go stockage · 7+ utilisateurs intensifs · multi-sites · SLA 99,9 % · SOC 2 Type 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>
|
||||||
81
templates/marketing/base.html
Normal 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>
|
||||||
152
templates/marketing/conformite.html
Normal 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 : 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 (QC)',
|
||||||
|
'desc': 'Stockage persistant chez OVHcloud Canada à Beauharnois, Québec. Traitement GPU temporaire sur GCP Toronto (Ontario) : RAM uniquement, durée maximale 5 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 25 (LPRPSP)',
|
||||||
|
'desc': 'Audit trail art. 3.5, EFVP signées art. 3.3 et 17 (GCP, HubSpot), registre des consentements art. 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 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 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 « Loi 25 ») 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. 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. 3.5',
|
||||||
|
'title': 'Audit trail intégré',
|
||||||
|
'desc': 'Chaque enregistrement, écoute, export, partage ou suppression est journalisé : 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. 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 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 : 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> ?
|
||||||
|
</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 %}
|
||||||
120
templates/marketing/contact.html
Normal 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 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 : 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 h à 17 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) 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 LOCAL et déploiements Cloud PRO corporatifs.
|
||||||
|
</p>
|
||||||
|
<address class="not-italic text-sm text-brand-navy/80 leading-relaxed">
|
||||||
|
77 ch. de la Seigneurie<br>
|
||||||
|
Inverness QC G0S 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 ?
|
||||||
|
</h2>
|
||||||
|
<p class="text-base text-brand-navy/80">
|
||||||
|
Pré-remplissez le sujet du courriel selon votre besoin :
|
||||||
|
</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 : bientôt disponible
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-brand-navy/70">
|
||||||
|
Le formulaire de contact en ligne ouvrira au lancement (printemps 2026). D'ici là, le courriel reste le canal officiel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
2389
templates/marketing/fonctionnalites.html
Normal file
2817
templates/marketing/landing.html
Normal file
143
templates/marketing/tarifs.html
Normal 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 LOCAL : <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 % local dont vous êtes propriétaire. Aucune limite utilisateurs, tarifs en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %).
|
||||||
|
</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 % hors-ligne)'},
|
||||||
|
{'name': 'GPU', 'basic': 'NVIDIA L4 partagé', 'ess': 'L4 partagé étendu', 'pro': 'L4 dédié priorité', 'local': 'RTX 5070 Ti 16 Go'},
|
||||||
|
{'name': 'Capacité audio', 'basic': '~165 h/mois', 'ess': '~330 h/mois', 'pro': '~660 h/mois', 'local': '~1 100 h/mois'},
|
||||||
|
{'name': 'Stockage', 'basic': '100 Go', 'ess': '200 Go', 'pro': '500 Go', 'local': '2 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’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 25', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check ~ '<span class="ml-1 text-xs">+ 100 % hors-ligne</span>'},
|
||||||
|
{'name': 'SLA', 'basic': '99,5 %', 'ess': '99,5 %', 'pro': '99,5 %', 'local': '— (resp. client)'},
|
||||||
|
{'name': 'Délai de mise en service', 'basic': '48 h', 'ess': '48 h', 'pro': '48 h + onboarding', 'local': '~2 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 > 660 h audio/mois, multi-sites ou SLA 99,9 %, demandez une <a href="/contact?pro-plus=1" class="grad-text font-semibold hover:underline">soumission Pro+</a>. Questions : <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 : les taxes (TPS 5 % + TVQ 9,975 %) et, pour DictIA Local, le matériel inclus dans le 5 998 $ An 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 998 $ de DictIA Local?', 'a': 'Le forfait DictIA Local An 1 inclut : PC + GPU RTX 5070 Ti 16 Go + 2 To SSD, installation sur site, configuration sécurité, formation équipe, et la première année de licence logicielle. Dès l\'An 2, seul le renouvellement annuel de 500 $/an (mises à jour + support) est facturé.'},
|
||||||
|
{'q': 'Comment fonctionne le 485 $ d\'onboarding Cloud Pro?', 'a': 'Le forfait Cloud Pro inclut un onboarding assisté unique (485 $) couvrant : configuration des comptes, importation des hotwords métier, formation équipe (1 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 % (équivalent ~10 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 : > 660 h audio/mois, > 500 Go de stockage, 7+ utilisateurs simultanés intensifs, multi-sites, SLA renforcé 99,9 %, audits SOC 2 Type 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> ?
|
||||||
|
</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 %}
|
||||||
@@ -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 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 25 incluse — consentement granulaire, hébergement au Québec." | safe }}</p>
|
||||||
|
|
||||||
// Apply dark mode
|
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply color scheme
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
|
|
||||||
// Remove all other theme classes
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the correct theme class
|
|
||||||
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">
|
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Create an Account</h2>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% 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)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
<form method="POST" action="{{ url_for('auth.signup') }}" class="space-y-4" novalidate>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
<div class="mb-4">
|
<div>
|
||||||
{{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<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>
|
||||||
{% if form.username.errors %}
|
{{ 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'}) }}
|
||||||
{{ 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)]") }}
|
{% if form.email.errors %}<p class="text-xs text-red-700 mt-1">{{ form.email.errors[0] }}</p>{% endif %}
|
||||||
<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>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div>
|
||||||
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<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>
|
||||||
{% if form.email.errors %}
|
{{ 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'}) }}
|
||||||
{{ 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)]") }}
|
{% if form.password.errors %}<p class="text-xs text-red-900 mt-1">{{ form.password.errors[0] }}</p>{% endif %}
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
<p id="password-help" class="text-xs text-brand-navy/70 mt-1">8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p>
|
||||||
{% 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>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div>
|
||||||
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<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>
|
||||||
{% if form.password.errors %}
|
{{ 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'}) }}
|
||||||
{{ 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)]") }}
|
{% if form.confirm_password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.confirm_password.errors[0] }}</p>{% endif %}
|
||||||
<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 %}
|
|
||||||
<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">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
{{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<div>
|
||||||
{% if form.confirm_password.errors %}
|
<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>
|
||||||
{{ 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)]") }}
|
{{ 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'}) }}
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
{% if form.first_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.first_name.errors[0] }}</p>{% endif %}
|
||||||
{% for error in form.confirm_password.errors %}
|
</div>
|
||||||
<span>{{ error }}</span>
|
<div>
|
||||||
{% endfor %}
|
<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>
|
||||||
|
{{ 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>
|
||||||
{% 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>
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<div>
|
||||||
{{ 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)]") }}
|
<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="text-center text-sm text-[var(--text-muted)]">
|
<div>
|
||||||
<span>Already have an account?</span>
|
<label for="ordre_pro" class="block text-sm font-medium text-brand-navy mb-1">Ordre professionnel</label>
|
||||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Login here</a>
|
{{ 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'}) }}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
|
||||||
|
<fieldset class="space-y-3 pt-4 mt-2 border-t border-brand-border">
|
||||||
|
<legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi 25" | safe }}</legend>
|
||||||
|
|
||||||
|
<label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
{{ 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'}) }}
|
||||||
|
<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 form.consent_cgu.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_cgu.errors[0] }}</p>{% endif %}
|
||||||
|
|
||||||
|
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
{{ 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'}) }}
|
||||||
|
<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 form.consent_confidentialite.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_confidentialite.errors[0] }}</p>{% endif %}
|
||||||
|
|
||||||
|
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
{{ 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>
|
||||||
|
|
||||||
|
<label for="consent_analytics" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
{{ 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'}) }}
|
||||||
|
<span>J'accepte les statistiques d'usage anonymisées (optionnel, désactivable à tout moment).</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{ 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'}) }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
<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>
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
// Hide loading overlay when page is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
78
tests/_run_email_service_dictia_windows.py
Normal 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)
|
||||||
74
tests/_run_legal_pages_windows.py
Normal 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)
|
||||||
81
tests/_run_oauth_magic_link_windows.py
Normal 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)
|
||||||
74
tests/_run_stripe_checkout_windows.py
Normal 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)
|
||||||
74
tests/_run_stripe_webhook_windows.py
Normal 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)
|
||||||
77
tests/_run_totp_mfa_windows.py
Normal 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)
|
||||||
77
tests/_run_webauthn_passkey_windows.py
Normal 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
@@ -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')
|
||||||
62
tests/test_blueprint_registration.py
Normal 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
@@ -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()
|
||||||
404
tests/test_email_service_dictia.py
Normal 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 '<img src=x onerror=alert(1)>' 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 '<script>alert(1)</script>' 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
@@ -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()
|
||||||