feat(i18n): francais comme langue principale, anglais accessible
Le site s'affiche desormais en francais par defaut, avec un selecteur de langue permettant de basculer vers l'anglais. Choix de conception : les chaines sources restent en anglais Elles servent d'identifiants gettext, et le francais est fourni par catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une seule langue -- la meme que ses commentaires et docstrings -- tandis que ce qu'un membre voit est du francais. Consequence qui rend la migration praticable : une chaine non encore traduite retombe en anglais, pas sur un identifiant brut. Les gabarits peuvent donc etre migres un par un sans jamais laisser le site a moitie casse. Selection de la langue (app/i18n.py) 1. choix explicite via le selecteur, garde en session 2. sinon en-tete Accept-Language du navigateur, restreint a fr et en 3. sinon francais Un choix explicite prime toujours, y compris sur un navigateur anglophone. Selecteur Extrait en partiel et inclus dans les deux branches de la mise en page : barre laterale une fois connecte, ET page d'authentification. Quelqu'un qui ne lit pas la langue courante doit pouvoir en changer AVANT de se connecter -- le laisser derriere l'authentification aurait ete un defaut d'accessibilite. Chaque langue est ecrite dans sa propre langue. La route /lang/<locale> valide le Referer avant de rediriger : sans ce controle, elle constituait une redirection ouverte. Migre dans cette passe navigation complete, page de connexion, les cinq pages d'erreur, et l'integralite des messages flash de routes/auth.py. 64 chaines, dont aucune non traduite. Verification 25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant ou une entree non traduite font echouer la suite. Sans cela, une compilation oubliee servirait de l'anglais partout, en silence et sans rien dans les journaux. Un test existant a du etre corrige, et c'est instructif test_login_failure_message_does_not_reveal_account_existence cherchait la sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en francais, elle etait absente des deux cotes, l'assertion passait, et le mode strict a signale le faux succes. Le test comparait donc l'anglais, pas le comportement. Il compare desormais les messages flash rendus, quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le test la documente toujours. Les catalogues .po ET .mo sont versionnes : le deploiement est un simple miroir de fichiers, sans etape de compilation. messages.pot, regenerable, ne l'est pas. docs/translations.md documente le processus, les deux pieges (concatenation de phrases, traduction a l'import), et l'etat de la migration. A noter pour la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees telles quelles, il faudra les passer par des attributs data- -- ce qui rejoint le chantier de sortie de unsafe-inline (OPS-010). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+25
-21
@@ -15,6 +15,7 @@ from app.extensions import db, hash_password, check_password, limiter
|
||||
from app.models import User, Player, ESPORT_GAMES
|
||||
from app.validators import RegisterSchema, LoginSchema
|
||||
from app.logging_config import log_auth_event
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse, urlencode
|
||||
import requests
|
||||
@@ -137,8 +138,8 @@ def login():
|
||||
log_auth_event('login.rejected.locked', username=username, user_id=user.id)
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
f'Please try again in {remaining} minute(s).',
|
||||
_('Account is locked due to too many failed attempts. '
|
||||
'Please try again in %(remaining)s minute(s).', remaining=remaining),
|
||||
'danger'
|
||||
)
|
||||
return render_template('pages/login.html')
|
||||
@@ -147,7 +148,7 @@ def login():
|
||||
if not user.is_active_account:
|
||||
log_auth_event('login.rejected.deactivated',
|
||||
username=username, user_id=user.id)
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
flash(_('This account has been deactivated.'), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
# Reset failed login attempts on successful login
|
||||
@@ -175,7 +176,7 @@ def login():
|
||||
next_page = request.args.get('next')
|
||||
if next_page and not is_safe_url(next_page):
|
||||
next_page = None
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
# Track failed login attempt
|
||||
@@ -188,20 +189,23 @@ def login():
|
||||
log_auth_event('account.locked', username=username, user_id=user.id,
|
||||
minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
_('Account locked after %(attempts)s failed attempts. '
|
||||
'Please try again in %(minutes)s minutes.',
|
||||
attempts=MAX_LOGIN_ATTEMPTS,
|
||||
minutes=LOCKOUT_DURATION_MINUTES),
|
||||
'danger'
|
||||
)
|
||||
else:
|
||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
||||
flash(
|
||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
||||
_('Login unsuccessful. %(remaining)s attempt(s) remaining '
|
||||
'before lockout.', remaining=remaining),
|
||||
'danger'
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
log_auth_event('login.failure.unknown_user', username=username)
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
flash(_('Login unsuccessful. Please check username and password.'), 'danger')
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
@@ -232,7 +236,7 @@ def register():
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||
flash(_('Incorrect CAPTCHA answer. Please try again.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields only on CAPTCHA failure
|
||||
form_data.pop('password', None)
|
||||
@@ -274,7 +278,7 @@ def register():
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
@@ -286,7 +290,7 @@ def register():
|
||||
)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
flash(_('Email already registered.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
@@ -332,7 +336,7 @@ def register():
|
||||
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
flash(_('Your account has been created! You can now log in.'), 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request — render empty form
|
||||
@@ -358,7 +362,7 @@ def discord_login():
|
||||
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||
# raise inside the query builder rather than report a configuration error.
|
||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||
flash('Discord OAuth2 is not configured.', 'danger')
|
||||
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||
@@ -398,15 +402,15 @@ def discord_callback():
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.',
|
||||
_('Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash('Discord authorization failed. No code received.', 'danger')
|
||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
@@ -430,11 +434,11 @@ def discord_callback():
|
||||
token_json = token_response.json()
|
||||
access_token = token_json.get('access_token')
|
||||
except requests.RequestException:
|
||||
flash('Failed to connect to Discord. Please try again.', 'danger')
|
||||
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
if not access_token:
|
||||
flash('Failed to obtain Discord access token.', 'danger')
|
||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||
@@ -449,7 +453,7 @@ def discord_callback():
|
||||
user_response.raise_for_status()
|
||||
user_data = user_response.json()
|
||||
except requests.RequestException:
|
||||
flash('Failed to fetch Discord user profile.', 'danger')
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
# Fetch the user's connected gaming accounts
|
||||
@@ -496,7 +500,7 @@ def discord_callback():
|
||||
'auto_select_games': auto_select_games,
|
||||
}
|
||||
|
||||
flash('Discord account connected! Your profile has been pre-filled.', 'success')
|
||||
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
|
||||
@@ -514,5 +518,5 @@ def logout():
|
||||
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
||||
logout_user()
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
flash(_('You have been logged out.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
Reference in New Issue
Block a user