feat(auth): B-2.4 OAuth Microsoft/Google + magic link (Loi 25 deferred consent)

Adds Microsoft 365 + Google OAuth providers (separate from the existing
generic OIDC SSO at src/auth/sso.py) and a passwordless magic-link login
flow. New OAuth signups capture Loi 25 art. 14 consents (4 granular
checkboxes) BEFORE creating the User row via /auth/oauth/finish-signup.

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

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

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

Tests: 16/16 PASS via Windows manual driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-27 23:29:24 -04:00
parent dd270bca9e
commit 0513e67838
14 changed files with 1576 additions and 176 deletions

View File

@@ -1,6 +1,6 @@
{% extends 'marketing/base.html' %}
{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
{% 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 %}
{% block content %}
@@ -11,6 +11,7 @@
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
{% if action == 'password_reset' %}Vérifiez votre courriel
{% elif action == 'verification_required' %}Vérification requise
{% elif action == 'magic_link' %}Lien de connexion envoyé
{% else %}Confirmez votre courriel{% endif %}
</h1>
@@ -19,6 +20,8 @@
Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1&#160;heure.
{% 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&nbsp;minutes" | safe }}.
{% else %}
Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24&#160;heures.
{% endif %}

View File

@@ -0,0 +1,50 @@
{% extends 'marketing/base.html' %}
{% block title %}Lien de connexion DictIA{% endblock %}
{% block description %}Recevez un lien magique pour vous connecter à DictIA sans mot de passe.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="magic-title">
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
<h1 id="magic-title" class="text-3xl font-black text-brand-navy mb-2">Lien de connexion</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "Recevez un lien par courriel pour vous connecter sans mot de passe. Le lien expire dans 15&nbsp;minutes." | safe }}</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded-lg 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-[0.5rem] 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-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
{{ "Recevoir le lien (expire dans 15&nbsp;minutes)" | safe }}
</button>
</form>
<p class="text-xs text-brand-navy/70 mt-4">
Pour des raisons de sécurité, le lien n'est envoyé qu'aux comptes dont le courriel est vérifié. Si vous ne recevez rien, vérifiez vos pourriels (spam).
</p>
<p class="text-center text-sm text-brand-navy/70 mt-6">
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold hover:underline">&larr; Retour à la connexion</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends 'marketing/base.html' %}
{% block title %}Finaliser votre inscription DictIA{% endblock %}
{% block description %}Finalisez votre inscription DictIA — consentements Loi&nbsp;25 requis pour créer votre compte.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="finish-title">
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
<h1 id="finish-title" class="text-3xl font-black text-brand-navy mb-2">Finaliser votre inscription</h1>
<p class="text-sm text-brand-navy/70 mb-6">
Vous vous inscrivez via <strong>{{ provider_display or provider | capitalize }}</strong>. Avant de créer votre compte DictIA, nous devons obtenir vos consentements conformément à la {{ "Loi&nbsp;25" | safe }} du Québec.
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded-lg 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-[0.5rem] p-3 mb-6 text-sm">
<p class="text-brand-navy/70 mb-1">Compte fédéré :</p>
<p class="text-brand-navy font-semibold break-all">{{ userinfo.email }}</p>
{% if userinfo.name %}<p class="text-brand-navy/80 text-xs mt-1">{{ userinfo.name }}</p>{% endif %}
</div>
<form method="POST" action="{{ url_for('auth.oauth_finish_signup') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
<fieldset class="space-y-3 pt-2">
<legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi&nbsp;25" | safe }}</legend>
<label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_cgu" name="consent_cgu" value="y" required aria-required="true"
class="mt-1 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 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 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 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-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Créer mon compte DictIA
</button>
</form>
<p class="text-center text-sm text-brand-navy/70 mt-6">
Vous voulez utiliser un autre courriel&nbsp;?
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Inscription manuelle</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -1,178 +1,120 @@
<!DOCTYPE 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') }}">
{% extends 'marketing/base.html' %}
<!-- Loading overlay to prevent FOUC -->
{% include 'includes/loading_overlay.html' %}
{% block title %}Connexion — DictIA{% endblock %}
{% block description %}Connectez-vous à votre compte DictIA. Microsoft 365, Google, lien magique ou mot de passe.{% endblock %}
<script>
// Function to apply the theme based on localStorage
function applyTheme() {
// Guard against early execution
if (!document.documentElement) return;
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="login-title">
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
<h1 id="login-title" class="text-3xl font-black text-brand-navy mb-2">Connexion</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "Bienvenue sur DictIA — la transcription IA conforme à la Loi&nbsp;25." | safe }}</p>
// Apply dark mode
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');
}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded-lg 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 %}
// 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}`);
});
{# OAuth providers (Microsoft 365 + Google) — rendered only if env-enabled #}
{% if oauth_microsoft_enabled or oauth_google_enabled or sso_enabled %}
<div class="space-y-3 mb-6" aria-label="Connexion fédérée">
{% 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-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
{# Official Microsoft 4-square logo #}
<svg width="20" height="20" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
<rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
</svg>
<span>Continuer avec Microsoft 365</span>
</a>
{% endif %}
// 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>
{% 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-[0.75rem] 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 %}
<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) %}
{% if 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 %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if sso_enabled %}
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}">
<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">
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }}
</a>
{% if not password_login_disabled %}
<div class="flex items-center text-xs text-[var(--text-muted)]">
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
<span class="mx-3 uppercase tracking-wide">ou</span>
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
</div>
{% endif %}
</div>
{% endif %}
{% if 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-[0.75rem] 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 password_login_disabled %}
<div class="mt-4 text-center">
<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() }}
<div class="mb-4">
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Email administrateur") }}
</div>
<div class="mb-4">
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Mot de passe") }}
</div>
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
Se connecter
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
{% if not password_login_disabled %}
<div class="flex items-center text-xs uppercase tracking-wide text-brand-navy/60 my-3">
<span class="flex-grow border-t border-brand-border"></span>
<span class="mx-3">ou</span>
<span class="flex-grow border-t border-brand-border"></span>
</div>
{% endif %}
</div>
{% endif %}
<div class="mb-4">
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.email.errors %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.email.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
{% if not password_login_disabled %}
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-4" novalidate>
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.password.errors %}
{{ 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)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div>
<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-[0.5rem] 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 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>
<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-[0.5rem] 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 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="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':'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é&nbsp;?</a>
</div>
<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>
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Se connecter
</button>
</form>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<p class="text-center text-sm mt-4">
<a href="{{ url_for('auth.magic_link_request') }}" class="grad-text font-semibold hover:underline">
{{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }}
</a>
</p>
{% endif %}
<script>
// Hide loading overlay when page is ready
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>
<p class="text-center text-sm text-brand-navy/70 mt-6">
Pas encore de compte&nbsp;?
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Créer un compte</a>
</p>
</div>
</section>
{% endblock %}