From d8541678a63464de89ea4594d0892b22833c39c3 Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 12:14:50 -0400 Subject: [PATCH] fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests. --- app/routes/auth.py | 191 ++++---- app/static/css/style.css | 9 + app/templates/pages/register.html | 11 +- app/translations/en/LC_MESSAGES/messages.mo | Bin 43862 -> 43864 bytes app/translations/en/LC_MESSAGES/messages.po | 470 ++++++++++---------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 48065 -> 48062 bytes app/translations/fr/LC_MESSAGES/messages.po | 470 ++++++++++---------- tests/test_registration_screening.py | 217 +++++++++ tests/test_transactions.py | 11 +- 9 files changed, 823 insertions(+), 556 deletions(-) create mode 100644 tests/test_registration_screening.py diff --git a/app/routes/auth.py b/app/routes/auth.py index ff0d383..0bff41d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -2,12 +2,12 @@ This module handles user authentication including login with account lockout protection, logout with session clearing, and new user registration with -password policy enforcement and CAPTCHA verification. +password policy enforcement and sign-up screening. """ import os import secrets -import uuid +import time from datetime import datetime, timedelta from urllib.parse import urlencode, urlparse @@ -38,6 +38,18 @@ MAX_LOCKOUT_MINUTES = 240 #: response time stops telling a caller which usernames exist (SEC-017). _ABSENT_USER_HASH = None +#: Session key recording when the registration form was handed out. +REGISTRATION_ISSUED_KEY = 'registration_form_issued_at' + +#: Name of the honeypot input. Plausible enough that a form-filler wants it, +#: absent from the visible form. Hidden by .honeypot in style.css — not by an +#: inline style, so that the rule survives a tightening of style-src. +REGISTRATION_HONEYPOT_FIELD = 'website' + +#: Floor on how long a genuine registration takes. Eleven fields and a +#: password typed twice; three seconds is generous. +MIN_REGISTRATION_SECONDS = 3 + # Discord OAuth2 configuration DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID') DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET') @@ -116,41 +128,65 @@ def cooloff_minutes(failed_attempts): return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES) -def generate_captcha(): - """Generate a simple math CAPTCHA challenge. +def issue_registration_challenge(): + """Mark that the registration form has been handed out, and when. - Creates a random addition problem and stores the answer in the session. + Kept in the signed session rather than in a form field, so that the + timestamp is not something the submitter can choose. Left in place across + a failed submission: someone correcting a typo should not be told to slow + down, and a robot has already paid for the round trip by then. + """ + session.setdefault(REGISTRATION_ISSUED_KEY, time.time()) + + +def check_registration_challenge(form): + """Say why this registration should be refused, or None to accept. + + What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008). + + `a + b = ?` with both operands between 1 and 10 has nineteen possible + answers and is solvable by reading the string. It stopped no automated + registration whatsoever. What it did do was add a step for every human, + including anyone using a screen reader, in exchange for an appearance of + protection — which is worse than no protection, because it gets counted + as one. + + The audit's alternative was a real CAPTCHA service. That means a third + party, an API key, a request on every page load, and putting a foreign + script back into script-src — undoing the CSP work that closed + SEC-WEB-001. Disproportionate for a club site. + + So: two checks that cost the visitor nothing. + + - a honeypot field, hidden in the stylesheet, that a form-filling + robot completes and a person never sees; + - a minimum dwell time between being handed the form and sending it + back. Eleven fields and a password typed twice do not get filled in + under three seconds, and a POST with no issued form at all never + fetched the page. + + Be clear about the ceiling: this stops commodity spam, not somebody who + looks at the form for five minutes. The thing that would actually gate + registration is staff activation of new accounts, which does not exist — + `is_active_account` defaults to True. That is a product decision, not one + to slip in here. + + The session-forgery angle in the constat is moot: with SECRET_KEY + compromised (SEC-001) an attacker forges a logged-in session for any + account and has no reason to register at all. Returns: - dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys. + str | None: a short reason for the log, or None to let it through. """ - import random + if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip(): + return 'honeypot' - a = random.randint(1, 10) - b = random.randint(1, 10) - captcha_id = str(uuid.uuid4()) - session['captcha_id'] = captcha_id - session['captcha_answer'] = a + b - return {'question': f'{a} + {b} = ?', 'id': captcha_id} - - -def verify_captcha(user_answer): - """Verify the CAPTCHA answer from the session. - - Args: - user_answer: The user's submitted answer (string or int). - - Returns: - bool: True if the answer matches the stored CAPTCHA, False otherwise. - """ - try: - expected = session.pop('captcha_answer', None) - session.pop('captcha_id', None) - if expected is None: - return False - return int(user_answer) == expected - except (ValueError, TypeError): - return False + issued_at = session.get(REGISTRATION_ISSUED_KEY) + if issued_at is None: + return 'no-form-issued' + if time.time() - issued_at < MIN_REGISTRATION_SECONDS: + return 'too-fast' + return None auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @@ -276,14 +312,33 @@ def login(): return render_template('pages/login.html') +def _rerender_registration(form_data): + """Re-render the registration form after a refusal. + + Was copied out four times, near-identically (ARCH-005). Dropping the two + password fields is the part that must not be forgotten in the fifth copy: + echoing a password back into the HTML puts it in the browser's cache and + in any proxy log along the way. + """ + form_data = dict(form_data) + form_data.pop('password', None) + form_data.pop('confirm_password', None) + return render_template( + 'pages/register.html', + esport_games=ESPORT_GAMES, + honeypot_field=REGISTRATION_HONEYPOT_FIELD, + form_data=form_data, + ) + + @auth_bp.route('/register', methods=['GET', 'POST']) @limiter.limit("20 per hour") def register(): - """Handle new player registration with CAPTCHA and password policy. + """Handle new player registration. - GET: Render the registration form with E-Sports games list and CAPTCHA. - POST: Validate all inputs, verify CAPTCHA, enforce password policy, - and create a new player account. + GET: Render the registration form with the E-Sports games list. + POST: Screen the submission (see check_registration_challenge), validate + every input against RegisterSchema, and create a new player account. Only players can register through this form. Validates username/email uniqueness and password confirmation. @@ -299,20 +354,15 @@ def register(): form_data = dict(request.form) form_data['games'] = request.form.getlist('games') - # Validate CAPTCHA first - captcha_answer = request.form.get('captcha_answer', '') - if not verify_captcha(captcha_answer): - flash(_('Incorrect CAPTCHA answer. Please try again.'), 'danger') - captcha = generate_captcha() - # Clear password fields only on CAPTCHA failure - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + refusal = check_registration_challenge(request.form) + if refusal is not None: + # Logged, because this is the only place abuse of the sign-up + # form becomes visible at all. Deliberately vague to the sender: + # naming the honeypot tells whoever tripped it how to avoid it. + log_auth_event('account.registration_refused', reason=refusal) + flash(_('Your registration could not be processed. Please try again.'), 'danger') + issue_registration_challenge() + return _rerender_registration(form_data) # Validate input with marshmallow schema register_schema = RegisterSchema() @@ -322,16 +372,7 @@ def register(): for field, messages in err.messages.items(): for msg in messages: flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') - captcha = generate_captcha() - # Clear password fields on validation failure - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) username = validated['username'] email = validated['email'] @@ -345,27 +386,11 @@ def register(): if User.query.filter_by(username=username).first(): flash(_('Username already exists.'), 'danger') - captcha = generate_captcha() - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) if User.query.filter_by(email=email).first(): flash(_('Email already registered.'), 'danger') - captcha = generate_captcha() - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) hashed_password = hash_password(password) user = Player( @@ -404,6 +429,7 @@ def register(): # Clear Discord OAuth data from session after successful registration session.pop('discord_oauth', None) + session.pop(REGISTRATION_ISSUED_KEY, None) log_auth_event('account.registered', username=user.username, user_id=user.id) @@ -411,13 +437,8 @@ def register(): return redirect(url_for('auth.login')) # GET request — render empty form - captcha = generate_captcha() - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data={}, - ) + issue_registration_challenge() + return _rerender_registration({}) @auth_bp.route('/discord/login') diff --git a/app/static/css/style.css b/app/static/css/style.css index 48cff1e..20d2aea 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2021,3 +2021,12 @@ a:hover { color: var(--primary-dark); } white-space: nowrap; border: 0; } + +/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008). + The opposite of .sr-only above — that one hides from the eye and keeps the + announcement, this one has to hide from both. display:none is deliberate: + an off-screen input is still reachable by keyboard and by a screen reader, + and a person who lands in it gets refused with no idea why. */ +.honeypot { + display: none; +} diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index dc8a03b..9534206 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -146,9 +146,14 @@ required> -
- - + {# Honeypot (SEC-AUTH-008). Not a field anyone is meant to see or fill: + hidden in the stylesheet, kept out of the tab order, and told to + screen readers to skip. A submission that carries a value here is a + robot filling every input it can find. + Do not add a label, do not translate the name, do not remove + aria-hidden — each of those turns it into a trap for a person. #} + diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 87c678d579cab70e39682040c6e608f99fc2e429..780c525e23029e98a731266b32ba5d640a07a6cc 100644 GIT binary patch delta 10286 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\n" "Language: en\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/validators.py:49 +#: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." @@ -27,95 +27,95 @@ msgstr "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." -#: app/validators.py:67 +#: app/validators.py:68 msgid "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." msgstr "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." -#: app/validators.py:86 +#: app/validators.py:87 msgid "Invalid Discord username format." msgstr "Invalid Discord username format." -#: app/validators.py:103 +#: app/validators.py:104 msgid "Discord User ID must be a 17-20 digit number." msgstr "Discord User ID must be a 17-20 digit number." -#: app/validators.py:121 +#: app/validators.py:122 msgid "Invalid phone number format." msgstr "Invalid phone number format." -#: app/validators.py:163 +#: app/validators.py:164 msgid "Username is required." msgstr "Username is required." -#: app/validators.py:167 +#: app/validators.py:168 msgid "Password is required." msgstr "Password is required." -#: app/validators.py:189 app/validators.py:261 +#: app/validators.py:190 app/validators.py:262 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." -#: app/validators.py:195 +#: app/validators.py:196 msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:208 app/validators.py:276 app/validators.py:307 -#: app/validators.py:371 +#: app/validators.py:209 app/validators.py:277 app/validators.py:308 +#: app/validators.py:372 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:243 +#: app/validators.py:244 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:280 app/validators.py:315 +#: app/validators.py:281 app/validators.py:316 msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:416 +#: app/validators.py:417 msgid "Player must be selected." msgstr "Player must be selected." -#: app/validators.py:419 +#: app/validators.py:420 msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:438 +#: app/validators.py:439 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:443 app/validators.py:470 +#: app/validators.py:444 app/validators.py:471 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:447 +#: app/validators.py:448 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:450 +#: app/validators.py:451 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:466 +#: app/validators.py:467 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/routes/auth.py:186 app/routes/auth.py:322 app/routes/users.py:106 -#: app/routes/users.py:821 +#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 +#: app/routes/users/contracts.py:95 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" -#: app/routes/auth.py:203 +#: app/routes/auth.py:241 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:238 +#: app/routes/auth.py:276 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:268 +#: app/routes/auth.py:306 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -123,27 +123,27 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:303 -msgid "Incorrect CAPTCHA answer. Please try again." -msgstr "Incorrect CAPTCHA answer. Please try again." +#: app/routes/auth.py:363 +msgid "Your registration could not be processed. Please try again." +msgstr "Your registration could not be processed. Please try again." -#: app/routes/auth.py:345 app/routes/users.py:436 +#: app/routes/auth.py:388 app/routes/users/accounts.py:308 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:357 app/routes/users.py:440 +#: app/routes/auth.py:392 app/routes/users/accounts.py:312 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:404 +#: app/routes/auth.py:436 msgid "Your account has been created! You can now log in." msgstr "Your account has been created! You can now log in." -#: app/routes/auth.py:430 +#: app/routes/auth.py:457 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:471 +#: app/routes/auth.py:498 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -151,418 +151,419 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:480 +#: app/routes/auth.py:507 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:504 +#: app/routes/auth.py:531 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:508 +#: app/routes/auth.py:535 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:523 +#: app/routes/auth.py:550 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:570 +#: app/routes/auth.py:597 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:598 +#: app/routes/auth.py:625 msgid "You have been logged out." msgstr "You have been logged out." -#: app/routes/evaluations.py:45 +#: app/routes/evaluations.py:46 msgid "You do not have permission to view evaluations." msgstr "You do not have permission to view evaluations." -#: app/routes/evaluations.py:135 +#: app/routes/evaluations.py:136 msgid "You do not have permission to evaluate players." msgstr "You do not have permission to evaluate players." -#: app/routes/evaluations.py:140 app/routes/evaluations.py:263 +#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 msgid "You do not have permission to evaluate players in this tryout." msgstr "You do not have permission to evaluate players in this tryout." -#: app/routes/evaluations.py:151 +#: app/routes/evaluations.py:152 msgid "Player is not registered for this tryout." msgstr "Player is not registered for this tryout." -#: app/routes/evaluations.py:156 +#: app/routes/evaluations.py:157 msgid "Can only evaluate players." msgstr "Can only evaluate players." -#: app/routes/evaluations.py:208 +#: app/routes/evaluations.py:209 msgid "Evaluation updated!" msgstr "Evaluation updated!" -#: app/routes/evaluations.py:228 +#: app/routes/evaluations.py:229 msgid "Evaluation submitted successfully!" msgstr "Evaluation submitted successfully!" -#: app/routes/evaluations.py:258 app/routes/teams.py:268 -#: app/routes/teams.py:309 app/routes/teams.py:350 app/routes/teams.py:375 -#: app/routes/teams.py:400 app/routes/teams.py:435 app/routes/tryouts.py:472 -#: app/routes/tryouts.py:488 app/routes/tryouts.py:508 -#: app/routes/tryouts.py:547 app/routes/tryouts.py:583 -#: app/routes/tryouts.py:602 +#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 +#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 +#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 +#: app/routes/tryouts.py:635 msgid "Permission denied." msgstr "Permission denied." -#: app/routes/main.py:53 +#: app/routes/main.py:55 msgid "That language is not available." msgstr "That language is not available." -#: app/routes/matches.py:245 +#: app/routes/matches.py:307 msgid "You do not have permission to schedule matches for this tryout." msgstr "You do not have permission to schedule matches for this tryout." -#: app/routes/matches.py:249 app/routes/matches.py:422 +#: app/routes/matches.py:311 app/routes/matches.py:473 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "This tryout has ended. Matches can no longer be created or modified." -#: app/routes/matches.py:270 +#: app/routes/matches.py:332 msgid "Start time is required. Please select a time slot." msgstr "Start time is required. Please select a time slot." -#: app/routes/matches.py:282 app/routes/matches.py:445 -#: app/routes/team_matches.py:151 app/routes/team_matches.py:255 +#: app/routes/matches.py:344 app/routes/matches.py:496 +#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 msgid "Invalid date format." msgstr "Invalid date format." -#: app/routes/matches.py:302 app/routes/team_matches.py:172 +#: app/routes/matches.py:364 app/routes/team_matches.py:174 msgid "Invalid time format." msgstr "Invalid time format." -#: app/routes/matches.py:398 +#: app/routes/matches.py:449 msgid "Match scheduled successfully!" msgstr "Match scheduled successfully!" -#: app/routes/matches.py:418 app/routes/team_matches.py:242 +#: app/routes/matches.py:469 app/routes/team_matches.py:234 msgid "You do not have permission to edit this match." msgstr "You do not have permission to edit this match." -#: app/routes/matches.py:456 +#: app/routes/matches.py:507 msgid "Start time is required." msgstr "Start time is required." -#: app/routes/matches.py:578 app/routes/team_matches.py:284 +#: app/routes/matches.py:616 app/routes/team_matches.py:276 msgid "Match updated successfully!" msgstr "Match updated successfully!" -#: app/routes/matches.py:631 app/routes/team_matches.py:299 +#: app/routes/matches.py:669 app/routes/team_matches.py:291 msgid "You do not have permission to delete this match." msgstr "You do not have permission to delete this match." -#: app/routes/matches.py:634 +#: app/routes/matches.py:672 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "This tryout has ended. Matches can no longer be deleted." -#: app/routes/matches.py:647 app/routes/team_matches.py:303 +#: app/routes/matches.py:685 app/routes/team_matches.py:295 msgid "Match deleted successfully." msgstr "Match deleted successfully." -#: app/routes/team_matches.py:98 +#: app/routes/team_matches.py:100 msgid "You do not have permission to schedule matches for this team." msgstr "You do not have permission to schedule matches for this team." -#: app/routes/team_matches.py:140 +#: app/routes/team_matches.py:142 msgid "Date is required." msgstr "Date is required." -#: app/routes/team_matches.py:228 +#: app/routes/team_matches.py:220 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Team match \"%(title)s\" scheduled successfully!" -#: app/routes/team_matches.py:267 +#: app/routes/team_matches.py:259 msgid "Invalid start time format." msgstr "Invalid start time format." -#: app/routes/team_matches.py:275 +#: app/routes/team_matches.py:267 msgid "Invalid end time format." msgstr "Invalid end time format." -#: app/routes/teams.py:38 +#: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Use My Team(s) to view your teams." -#: app/routes/teams.py:41 +#: app/routes/teams.py:43 msgid "You do not have permission to view teams." msgstr "You do not have permission to view teams." -#: app/routes/teams.py:68 +#: app/routes/teams.py:70 msgid "This page is for players." msgstr "This page is for players." -#: app/routes/teams.py:121 +#: app/routes/teams.py:123 msgid "You do not have permission to create teams." msgstr "You do not have permission to create teams." -#: app/routes/teams.py:129 app/routes/teams.py:174 +#: app/routes/teams.py:131 app/routes/teams.py:176 msgid "Team name is required." msgstr "Team name is required." -#: app/routes/teams.py:134 app/routes/teams.py:179 +#: app/routes/teams.py:136 app/routes/teams.py:181 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "Team \"%(name)s\" already exists." -#: app/routes/teams.py:156 +#: app/routes/teams.py:158 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Team \"%(name)s\" created successfully!" -#: app/routes/teams.py:166 +#: app/routes/teams.py:168 msgid "You do not have permission to edit this team." msgstr "You do not have permission to edit this team." -#: app/routes/teams.py:217 +#: app/routes/teams.py:219 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Team \"%(name)s\" updated successfully!" -#: app/routes/teams.py:226 +#: app/routes/teams.py:228 msgid "You do not have permission to delete teams." msgstr "You do not have permission to delete teams." -#: app/routes/teams.py:258 +#: app/routes/teams.py:260 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Team \"%(name)s\" deleted successfully." -#: app/routes/teams.py:273 +#: app/routes/teams.py:275 msgid "Please select a coach." msgstr "Please select a coach." -#: app/routes/teams.py:278 +#: app/routes/teams.py:280 msgid "Only coaches can be assigned as coach." msgstr "Only coaches can be assigned as coach." -#: app/routes/teams.py:284 +#: app/routes/teams.py:286 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s is already a coach of %(name)s." -#: app/routes/teams.py:297 +#: app/routes/teams.py:299 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s added as coach of %(name)s." -#: app/routes/teams.py:314 +#: app/routes/teams.py:316 msgid "Please select a manager." msgstr "Please select a manager." -#: app/routes/teams.py:319 +#: app/routes/teams.py:321 msgid "Only managers can be assigned as manager." msgstr "Only managers can be assigned as manager." -#: app/routes/teams.py:325 +#: app/routes/teams.py:327 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s is already a manager of %(name)s." -#: app/routes/teams.py:338 +#: app/routes/teams.py:340 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s added as manager of %(name)s." -#: app/routes/teams.py:365 +#: app/routes/teams.py:367 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach removed from %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:392 #, python-format msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:406 app/routes/tryouts.py:512 app/routes/tryouts.py:613 +#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 msgid "Please select a player." msgstr "Please select a player." -#: app/routes/teams.py:411 +#: app/routes/teams.py:413 msgid "Can only assign players to teams." msgstr "Can only assign players to teams." -#: app/routes/teams.py:417 +#: app/routes/teams.py:419 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s is already on %(name)s." -#: app/routes/teams.py:425 +#: app/routes/teams.py:427 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s added to %(name)s!" -#: app/routes/teams.py:442 app/routes/teams.py:515 +#: app/routes/teams.py:444 app/routes/teams.py:517 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s is not on %(name)s." -#: app/routes/teams.py:450 +#: app/routes/teams.py:452 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s removed from %(name)s." -#: app/routes/teams.py:486 app/routes/teams.py:504 +#: app/routes/teams.py:488 app/routes/teams.py:506 msgid "You do not have permission to add notes to this team." msgstr "You do not have permission to add notes to this team." -#: app/routes/teams.py:494 +#: app/routes/teams.py:496 msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:509 app/routes/users.py:1530 app/routes/users.py:1573 +#: app/routes/teams.py:511 app/routes/users/notes.py:207 +#: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Can only add notes for players." -#: app/routes/teams.py:525 +#: app/routes/teams.py:527 #, python-format msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" -#: app/routes/tryouts.py:56 +#: app/routes/tryouts.py:77 msgid "You do not have permission to create tryouts." msgstr "You do not have permission to create tryouts." -#: app/routes/tryouts.py:82 app/routes/tryouts.py:189 +#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/routes/tryouts.py:97 app/routes/tryouts.py:204 +#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/routes/tryouts.py:107 app/routes/tryouts.py:214 +#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/routes/tryouts.py:139 +#: app/routes/tryouts.py:160 msgid "Tryout created successfully!" msgstr "Tryout created successfully!" -#: app/routes/tryouts.py:159 +#: app/routes/tryouts.py:180 msgid "You do not have permission to edit this tryout." msgstr "You do not have permission to edit this tryout." -#: app/routes/tryouts.py:163 +#: app/routes/tryouts.py:184 msgid "This tryout has ended and can no longer be modified." msgstr "This tryout has ended and can no longer be modified." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:263 msgid "Tryout updated successfully!" msgstr "Tryout updated successfully!" -#: app/routes/tryouts.py:289 +#: app/routes/tryouts.py:310 msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:439 +#: app/routes/tryouts.py:472 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:476 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:483 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:456 app/routes/tryouts.py:531 +#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:462 +#: app/routes/tryouts.py:495 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:478 +#: app/routes/tryouts.py:511 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:498 +#: app/routes/tryouts.py:531 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:550 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:523 +#: app/routes/tryouts.py:556 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:537 +#: app/routes/tryouts.py:570 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:573 +#: app/routes/tryouts.py:606 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:591 +#: app/routes/tryouts.py:624 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:622 +#: app/routes/tryouts.py:655 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:628 +#: app/routes/tryouts.py:661 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:633 +#: app/routes/tryouts.py:666 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:643 +#: app/routes/tryouts.py:676 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:679 +#: app/routes/tryouts.py:712 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users.py:83 +#: app/routes/users/_shared.py:46 msgid "No file selected." msgstr "No file selected." -#: app/routes/users.py:87 +#: app/routes/users/_shared.py:50 msgid "Only PDF files are allowed for contracts." msgstr "Only PDF files are allowed for contracts." -#: app/routes/users.py:92 +#: app/routes/users/_shared.py:55 msgid "That file is not a PDF, whatever its name says." msgstr "That file is not a PDF, whatever its name says." -#: app/routes/users.py:181 +#: app/routes/users/accounts.py:53 msgid "Only the president can manage users." msgstr "Only the president can manage users." -#: app/routes/users.py:193 +#: app/routes/users/accounts.py:65 msgid "Only the president can edit users." msgstr "Only the president can edit users." -#: app/routes/users.py:234 +#: app/routes/users/accounts.py:106 msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users.py:245 +#: app/routes/users/accounts.py:117 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users.py:258 +#: app/routes/users/accounts.py:130 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -570,175 +571,176 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users.py:337 +#: app/routes/users/accounts.py:209 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users.py:358 +#: app/routes/users/accounts.py:230 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users.py:362 +#: app/routes/users/accounts.py:234 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users.py:405 +#: app/routes/users/accounts.py:277 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users.py:416 +#: app/routes/users/accounts.py:288 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users.py:464 +#: app/routes/users/accounts.py:336 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" -#: app/routes/users.py:534 -msgid "Username already taken." -msgstr "Username already taken." +#: app/routes/users/availability.py:179 +msgid "Only coaches can manage availability." +msgstr "Only coaches can manage availability." -#: app/routes/users.py:544 -msgid "Email already in use." -msgstr "Email already in use." - -#: app/routes/users.py:574 -msgid "Profile updated successfully!" -msgstr "Profile updated successfully!" - -#: app/routes/users.py:809 +#: app/routes/users/contracts.py:83 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users.py:828 +#: app/routes/users/contracts.py:102 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users.py:869 +#: app/routes/users/contracts.py:143 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users.py:883 +#: app/routes/users/contracts.py:157 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users.py:902 +#: app/routes/users/contracts.py:176 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users.py:912 app/routes/users.py:925 +#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users.py:928 +#: app/routes/users/contracts.py:202 msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users.py:1034 -msgid "Only players can request One on One sessions." -msgstr "Only players can request One on One sessions." - -#: app/routes/users.py:1047 -msgid "You do not have a coach assigned to your team." -msgstr "You do not have a coach assigned to your team." - -#: app/routes/users.py:1082 -msgid "Cannot request One on One - no coach assigned." -msgstr "Cannot request One on One - no coach assigned." - -#: app/routes/users.py:1090 -msgid "Invalid date or time format." -msgstr "Invalid date or time format." - -#: app/routes/users.py:1104 -msgid "The requested time is not within the coach's availability." -msgstr "The requested time is not within the coach's availability." - -#: app/routes/users.py:1132 -msgid "Your One on One request has been submitted!" -msgstr "Your One on One request has been submitted!" - -#: app/routes/users.py:1176 -msgid "Only coaches can accept One on One requests." -msgstr "Only coaches can accept One on One requests." - -#: app/routes/users.py:1182 app/routes/users.py:1232 -msgid "This request is not for you." -msgstr "This request is not for you." - -#: app/routes/users.py:1186 app/routes/users.py:1236 -msgid "This request has already been processed." -msgstr "This request has already been processed." - -#: app/routes/users.py:1213 -#, python-format -msgid "One on One request from %(player)s has been approved!" -msgstr "One on One request from %(player)s has been approved!" - -#: app/routes/users.py:1226 -msgid "Only coaches can reject One on One requests." -msgstr "Only coaches can reject One on One requests." - -#: app/routes/users.py:1268 -#, python-format -msgid "One on One request from %(player)s has been rejected." -msgstr "One on One request from %(player)s has been rejected." - -#: app/routes/users.py:1286 +#: app/routes/users/notes.py:34 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users.py:1328 -msgid "Only coaches can manage availability." -msgstr "Only coaches can manage availability." - -#: app/routes/users.py:1394 +#: app/routes/users/notes.py:71 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users.py:1484 +#: app/routes/users/notes.py:161 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users.py:1490 +#: app/routes/users/notes.py:167 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users.py:1503 +#: app/routes/users/notes.py:180 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users.py:1518 +#: app/routes/users/notes.py:195 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users.py:1525 app/routes/users.py:1568 app/routes/users.py:1619 -#: app/routes/users.py:1673 +#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 +#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 msgid "Player and content are required." msgstr "Player and content are required." -#: app/routes/users.py:1534 app/routes/users.py:1577 app/routes/users.py:1623 -#: app/routes/users.py:1677 +#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 +#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users.py:1544 app/routes/users.py:1590 +#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users.py:1558 app/routes/users.py:1604 app/routes/users.py:1657 +#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 +#: app/routes/users/notes.py:334 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users.py:1634 app/routes/users.py:1688 +#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 msgid "Note added successfully." msgstr "Note added successfully." +#: app/routes/users/one_on_one.py:20 +msgid "Only players can request One on One sessions." +msgstr "Only players can request One on One sessions." + +#: app/routes/users/one_on_one.py:33 +msgid "You do not have a coach assigned to your team." +msgstr "You do not have a coach assigned to your team." + +#: app/routes/users/one_on_one.py:68 +msgid "Cannot request One on One - no coach assigned." +msgstr "Cannot request One on One - no coach assigned." + +#: app/routes/users/one_on_one.py:76 +msgid "Invalid date or time format." +msgstr "Invalid date or time format." + +#: app/routes/users/one_on_one.py:90 +msgid "The requested time is not within the coach's availability." +msgstr "The requested time is not within the coach's availability." + +#: app/routes/users/one_on_one.py:118 +msgid "Your One on One request has been submitted!" +msgstr "Your One on One request has been submitted!" + +#: app/routes/users/one_on_one.py:163 +msgid "Only coaches can accept One on One requests." +msgstr "Only coaches can accept One on One requests." + +#: app/routes/users/one_on_one.py:169 app/routes/users/one_on_one.py:219 +msgid "This request is not for you." +msgstr "This request is not for you." + +#: app/routes/users/one_on_one.py:173 app/routes/users/one_on_one.py:223 +msgid "This request has already been processed." +msgstr "This request has already been processed." + +#: app/routes/users/one_on_one.py:200 +#, python-format +msgid "One on One request from %(player)s has been approved!" +msgstr "One on One request from %(player)s has been approved!" + +#: app/routes/users/one_on_one.py:213 +msgid "Only coaches can reject One on One requests." +msgstr "Only coaches can reject One on One requests." + +#: app/routes/users/one_on_one.py:255 +#, python-format +msgid "One on One request from %(player)s has been rejected." +msgstr "One on One request from %(player)s has been rejected." + +#: app/routes/users/profile.py:83 +msgid "Username already taken." +msgstr "Username already taken." + +#: app/routes/users/profile.py:93 +msgid "Email already in use." +msgstr "Email already in use." + +#: app/routes/users/profile.py:123 +msgid "Profile updated successfully!" +msgstr "Profile updated successfully!" + #: app/templates/errors/400.html:2 msgid "400 Bad Request" msgstr "400 Bad Request" @@ -2372,11 +2374,7 @@ msgstr "Confirm Password" msgid "Confirm your password" msgstr "Confirm your password" -#: app/templates/pages/register.html:151 -msgid "Answer" -msgstr "Answer" - -#: app/templates/pages/register.html:154 +#: app/templates/pages/register.html:159 msgid "Create Account" msgstr "Create Account" @@ -2876,3 +2874,9 @@ msgstr "View Profile" #~ msgid "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout." #~ msgstr "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout." +#~ msgid "Incorrect CAPTCHA answer. Please try again." +#~ msgstr "Incorrect CAPTCHA answer. Please try again." + +#~ msgid "Answer" +#~ msgstr "Answer" + diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index 6936029d68087e94423bcb235dc58de5fb00caf4..fcd5752cd63c25513642df108945e5b7108ac0d5 100644 GIT binary patch delta 10296 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 Yno{X{r&}??{&mOD$Uv!vp^Z)<= delta 10299 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\n" "Language: fr\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/validators.py:49 +#: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." @@ -27,97 +27,97 @@ msgstr "" "Le mot de passe doit compter au moins 8 caractères, dont une majuscule, " "une minuscule et un chiffre." -#: app/validators.py:67 +#: app/validators.py:68 msgid "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." msgstr "" "Le nom d’utilisateur doit compter de 3 à 30 caractères (lettres, " "chiffres, tiret bas, trait d’union)." -#: app/validators.py:86 +#: app/validators.py:87 msgid "Invalid Discord username format." msgstr "Format de nom d’utilisateur Discord invalide." -#: app/validators.py:103 +#: app/validators.py:104 msgid "Discord User ID must be a 17-20 digit number." msgstr "L’identifiant Discord doit être un nombre de 17 à 20 chiffres." -#: app/validators.py:121 +#: app/validators.py:122 msgid "Invalid phone number format." msgstr "Format de numéro de téléphone invalide." -#: app/validators.py:163 +#: app/validators.py:164 msgid "Username is required." msgstr "Le nom d’utilisateur est obligatoire." -#: app/validators.py:167 +#: app/validators.py:168 msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:189 app/validators.py:261 +#: app/validators.py:190 app/validators.py:262 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." -#: app/validators.py:195 +#: app/validators.py:196 msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:208 app/validators.py:276 app/validators.py:307 -#: app/validators.py:371 +#: app/validators.py:209 app/validators.py:277 app/validators.py:308 +#: app/validators.py:372 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:243 +#: app/validators.py:244 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:280 app/validators.py:315 +#: app/validators.py:281 app/validators.py:316 msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:416 +#: app/validators.py:417 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." -#: app/validators.py:419 +#: app/validators.py:420 msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:438 +#: app/validators.py:439 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:443 app/validators.py:470 +#: app/validators.py:444 app/validators.py:471 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:447 +#: app/validators.py:448 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:450 +#: app/validators.py:451 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:466 +#: app/validators.py:467 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/routes/auth.py:186 app/routes/auth.py:322 app/routes/users.py:106 -#: app/routes/users.py:821 +#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 +#: app/routes/users/contracts.py:95 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" -#: app/routes/auth.py:203 +#: app/routes/auth.py:241 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:238 +#: app/routes/auth.py:276 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:268 +#: app/routes/auth.py:306 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -125,27 +125,27 @@ msgstr "" "Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " "ou demandez de l’aide à un président." -#: app/routes/auth.py:303 -msgid "Incorrect CAPTCHA answer. Please try again." -msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." +#: app/routes/auth.py:363 +msgid "Your registration could not be processed. Please try again." +msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer." -#: app/routes/auth.py:345 app/routes/users.py:436 +#: app/routes/auth.py:388 app/routes/users/accounts.py:308 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:357 app/routes/users.py:440 +#: app/routes/auth.py:392 app/routes/users/accounts.py:312 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:404 +#: app/routes/auth.py:436 msgid "Your account has been created! You can now log in." msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter." -#: app/routes/auth.py:430 +#: app/routes/auth.py:457 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:471 +#: app/routes/auth.py:498 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -153,422 +153,423 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:480 +#: app/routes/auth.py:507 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:504 +#: app/routes/auth.py:531 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:508 +#: app/routes/auth.py:535 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:523 +#: app/routes/auth.py:550 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:570 +#: app/routes/auth.py:597 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:598 +#: app/routes/auth.py:625 msgid "You have been logged out." msgstr "Vous avez été déconnecté." -#: app/routes/evaluations.py:45 +#: app/routes/evaluations.py:46 msgid "You do not have permission to view evaluations." msgstr "Vous n’avez pas les droits pour consulter les évaluations." -#: app/routes/evaluations.py:135 +#: app/routes/evaluations.py:136 msgid "You do not have permission to evaluate players." msgstr "Vous n’avez pas les droits pour évaluer des joueurs." -#: app/routes/evaluations.py:140 app/routes/evaluations.py:263 +#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 msgid "You do not have permission to evaluate players in this tryout." msgstr "Vous n’avez pas les droits pour évaluer des joueurs dans cette sélection." -#: app/routes/evaluations.py:151 +#: app/routes/evaluations.py:152 msgid "Player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/evaluations.py:156 +#: app/routes/evaluations.py:157 msgid "Can only evaluate players." msgstr "Seuls des joueurs peuvent être évalués." -#: app/routes/evaluations.py:208 +#: app/routes/evaluations.py:209 msgid "Evaluation updated!" msgstr "Évaluation mise à jour." -#: app/routes/evaluations.py:228 +#: app/routes/evaluations.py:229 msgid "Evaluation submitted successfully!" msgstr "Évaluation enregistrée." -#: app/routes/evaluations.py:258 app/routes/teams.py:268 -#: app/routes/teams.py:309 app/routes/teams.py:350 app/routes/teams.py:375 -#: app/routes/teams.py:400 app/routes/teams.py:435 app/routes/tryouts.py:472 -#: app/routes/tryouts.py:488 app/routes/tryouts.py:508 -#: app/routes/tryouts.py:547 app/routes/tryouts.py:583 -#: app/routes/tryouts.py:602 +#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 +#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 +#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 +#: app/routes/tryouts.py:635 msgid "Permission denied." msgstr "Accès refusé." -#: app/routes/main.py:53 +#: app/routes/main.py:55 msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:245 +#: app/routes/matches.py:307 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:249 app/routes/matches.py:422 +#: app/routes/matches.py:311 app/routes/matches.py:473 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:270 +#: app/routes/matches.py:332 msgid "Start time is required. Please select a time slot." msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." -#: app/routes/matches.py:282 app/routes/matches.py:445 -#: app/routes/team_matches.py:151 app/routes/team_matches.py:255 +#: app/routes/matches.py:344 app/routes/matches.py:496 +#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 msgid "Invalid date format." msgstr "Format de date invalide." -#: app/routes/matches.py:302 app/routes/team_matches.py:172 +#: app/routes/matches.py:364 app/routes/team_matches.py:174 msgid "Invalid time format." msgstr "Format d’heure invalide." -#: app/routes/matches.py:398 +#: app/routes/matches.py:449 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:418 app/routes/team_matches.py:242 +#: app/routes/matches.py:469 app/routes/team_matches.py:234 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:456 +#: app/routes/matches.py:507 msgid "Start time is required." msgstr "L’heure de début est obligatoire." -#: app/routes/matches.py:578 app/routes/team_matches.py:284 +#: app/routes/matches.py:616 app/routes/team_matches.py:276 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:631 app/routes/team_matches.py:299 +#: app/routes/matches.py:669 app/routes/team_matches.py:291 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:634 +#: app/routes/matches.py:672 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:647 app/routes/team_matches.py:303 +#: app/routes/matches.py:685 app/routes/team_matches.py:295 msgid "Match deleted successfully." msgstr "Match supprimé." -#: app/routes/team_matches.py:98 +#: app/routes/team_matches.py:100 msgid "You do not have permission to schedule matches for this team." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette équipe." -#: app/routes/team_matches.py:140 +#: app/routes/team_matches.py:142 msgid "Date is required." msgstr "La date est obligatoire." -#: app/routes/team_matches.py:228 +#: app/routes/team_matches.py:220 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Match d’équipe « %(title)s » planifié." -#: app/routes/team_matches.py:267 +#: app/routes/team_matches.py:259 msgid "Invalid start time format." msgstr "Format d’heure de début invalide." -#: app/routes/team_matches.py:275 +#: app/routes/team_matches.py:267 msgid "Invalid end time format." msgstr "Format d’heure de fin invalide." -#: app/routes/teams.py:38 +#: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes." -#: app/routes/teams.py:41 +#: app/routes/teams.py:43 msgid "You do not have permission to view teams." msgstr "Vous n’avez pas les droits pour consulter les équipes." -#: app/routes/teams.py:68 +#: app/routes/teams.py:70 msgid "This page is for players." msgstr "Cette page est réservée aux joueurs." -#: app/routes/teams.py:121 +#: app/routes/teams.py:123 msgid "You do not have permission to create teams." msgstr "Vous n’avez pas les droits pour créer une équipe." -#: app/routes/teams.py:129 app/routes/teams.py:174 +#: app/routes/teams.py:131 app/routes/teams.py:176 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/routes/teams.py:134 app/routes/teams.py:179 +#: app/routes/teams.py:136 app/routes/teams.py:181 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "L’équipe « %(name)s » existe déjà." -#: app/routes/teams.py:156 +#: app/routes/teams.py:158 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Équipe « %(name)s » créée." -#: app/routes/teams.py:166 +#: app/routes/teams.py:168 msgid "You do not have permission to edit this team." msgstr "Vous n’avez pas les droits pour modifier cette équipe." -#: app/routes/teams.py:217 +#: app/routes/teams.py:219 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Équipe « %(name)s » mise à jour." -#: app/routes/teams.py:226 +#: app/routes/teams.py:228 msgid "You do not have permission to delete teams." msgstr "Vous n’avez pas les droits pour supprimer une équipe." -#: app/routes/teams.py:258 +#: app/routes/teams.py:260 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Équipe « %(name)s » supprimée." -#: app/routes/teams.py:273 +#: app/routes/teams.py:275 msgid "Please select a coach." msgstr "Veuillez choisir un coach." -#: app/routes/teams.py:278 +#: app/routes/teams.py:280 msgid "Only coaches can be assigned as coach." msgstr "Seuls les coachs peuvent être assignés comme coach." -#: app/routes/teams.py:284 +#: app/routes/teams.py:286 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s est déjà coach de %(name)s." -#: app/routes/teams.py:297 +#: app/routes/teams.py:299 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s a été ajouté comme coach de %(name)s." -#: app/routes/teams.py:314 +#: app/routes/teams.py:316 msgid "Please select a manager." msgstr "Veuillez choisir un gérant." -#: app/routes/teams.py:319 +#: app/routes/teams.py:321 msgid "Only managers can be assigned as manager." msgstr "Seuls les gérants peuvent être assignés comme gérant." -#: app/routes/teams.py:325 +#: app/routes/teams.py:327 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s est déjà gérant de %(name)s." -#: app/routes/teams.py:338 +#: app/routes/teams.py:340 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s a été ajouté comme gérant de %(name)s." -#: app/routes/teams.py:365 +#: app/routes/teams.py:367 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach retiré de %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:392 #, python-format msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:406 app/routes/tryouts.py:512 app/routes/tryouts.py:613 +#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 msgid "Please select a player." msgstr "Veuillez choisir un joueur." -#: app/routes/teams.py:411 +#: app/routes/teams.py:413 msgid "Can only assign players to teams." msgstr "Seuls des joueurs peuvent être assignés à une équipe." -#: app/routes/teams.py:417 +#: app/routes/teams.py:419 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s fait déjà partie de %(name)s." -#: app/routes/teams.py:425 +#: app/routes/teams.py:427 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s a été ajouté à %(name)s." -#: app/routes/teams.py:442 app/routes/teams.py:515 +#: app/routes/teams.py:444 app/routes/teams.py:517 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s ne fait pas partie de %(name)s." -#: app/routes/teams.py:450 +#: app/routes/teams.py:452 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:486 app/routes/teams.py:504 +#: app/routes/teams.py:488 app/routes/teams.py:506 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:494 +#: app/routes/teams.py:496 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:509 app/routes/users.py:1530 app/routes/users.py:1573 +#: app/routes/teams.py:511 app/routes/users/notes.py:207 +#: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:525 +#: app/routes/teams.py:527 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:56 +#: app/routes/tryouts.py:77 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:82 app/routes/tryouts.py:189 +#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/routes/tryouts.py:97 app/routes/tryouts.py:204 +#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 msgid "End date cannot be before start date." msgstr "La date de fin ne peut pas précéder la date de début." -#: app/routes/tryouts.py:107 app/routes/tryouts.py:214 +#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/routes/tryouts.py:139 +#: app/routes/tryouts.py:160 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:159 +#: app/routes/tryouts.py:180 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:163 +#: app/routes/tryouts.py:184 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:263 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:289 +#: app/routes/tryouts.py:310 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:439 +#: app/routes/tryouts.py:472 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:476 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:483 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:456 app/routes/tryouts.py:531 +#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:462 +#: app/routes/tryouts.py:495 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:478 +#: app/routes/tryouts.py:511 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:498 +#: app/routes/tryouts.py:531 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:550 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:523 +#: app/routes/tryouts.py:556 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:537 +#: app/routes/tryouts.py:570 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:573 +#: app/routes/tryouts.py:606 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:591 +#: app/routes/tryouts.py:624 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:622 +#: app/routes/tryouts.py:655 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:628 +#: app/routes/tryouts.py:661 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:633 +#: app/routes/tryouts.py:666 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:643 +#: app/routes/tryouts.py:676 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:679 +#: app/routes/tryouts.py:712 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users.py:83 +#: app/routes/users/_shared.py:46 msgid "No file selected." msgstr "Aucun fichier sélectionné." -#: app/routes/users.py:87 +#: app/routes/users/_shared.py:50 msgid "Only PDF files are allowed for contracts." msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." -#: app/routes/users.py:92 +#: app/routes/users/_shared.py:55 msgid "That file is not a PDF, whatever its name says." msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." -#: app/routes/users.py:181 +#: app/routes/users/accounts.py:53 msgid "Only the president can manage users." msgstr "Seul le président peut gérer les utilisateurs." -#: app/routes/users.py:193 +#: app/routes/users/accounts.py:65 msgid "Only the president can edit users." msgstr "Seul le président peut modifier des utilisateurs." -#: app/routes/users.py:234 +#: app/routes/users/accounts.py:106 msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users.py:245 +#: app/routes/users/accounts.py:117 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users.py:258 +#: app/routes/users/accounts.py:130 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -576,177 +577,178 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users.py:337 +#: app/routes/users/accounts.py:209 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users.py:358 +#: app/routes/users/accounts.py:230 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users.py:362 +#: app/routes/users/accounts.py:234 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users.py:405 +#: app/routes/users/accounts.py:277 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users.py:416 +#: app/routes/users/accounts.py:288 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users.py:464 +#: app/routes/users/accounts.py:336 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." -#: app/routes/users.py:534 -msgid "Username already taken." -msgstr "Ce nom d’utilisateur est déjà pris." +#: app/routes/users/availability.py:179 +msgid "Only coaches can manage availability." +msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users.py:544 -msgid "Email already in use." -msgstr "Cette adresse courriel est déjà utilisée." - -#: app/routes/users.py:574 -msgid "Profile updated successfully!" -msgstr "Profil mis à jour." - -#: app/routes/users.py:809 +#: app/routes/users/contracts.py:83 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users.py:828 +#: app/routes/users/contracts.py:102 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users.py:869 +#: app/routes/users/contracts.py:143 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users.py:883 +#: app/routes/users/contracts.py:157 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users.py:902 +#: app/routes/users/contracts.py:176 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users.py:912 app/routes/users.py:925 +#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users.py:928 +#: app/routes/users/contracts.py:202 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users.py:1034 -msgid "Only players can request One on One sessions." -msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." - -#: app/routes/users.py:1047 -msgid "You do not have a coach assigned to your team." -msgstr "Aucun coach n’est assigné à votre équipe." - -#: app/routes/users.py:1082 -msgid "Cannot request One on One - no coach assigned." -msgstr "Impossible de demander une rencontre : aucun coach assigné." - -#: app/routes/users.py:1090 -msgid "Invalid date or time format." -msgstr "Format de date ou d’heure invalide." - -#: app/routes/users.py:1104 -msgid "The requested time is not within the coach's availability." -msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." - -#: app/routes/users.py:1132 -msgid "Your One on One request has been submitted!" -msgstr "Votre demande de rencontre a été envoyée." - -#: app/routes/users.py:1176 -msgid "Only coaches can accept One on One requests." -msgstr "Seuls les coachs peuvent accepter une demande de rencontre." - -#: app/routes/users.py:1182 app/routes/users.py:1232 -msgid "This request is not for you." -msgstr "Cette demande ne vous est pas destinée." - -#: app/routes/users.py:1186 app/routes/users.py:1236 -msgid "This request has already been processed." -msgstr "Cette demande a déjà été traitée." - -#: app/routes/users.py:1213 -#, python-format -msgid "One on One request from %(player)s has been approved!" -msgstr "La demande de rencontre de %(player)s a été approuvée." - -#: app/routes/users.py:1226 -msgid "Only coaches can reject One on One requests." -msgstr "Seuls les coachs peuvent refuser une demande de rencontre." - -#: app/routes/users.py:1268 -#, python-format -msgid "One on One request from %(player)s has been rejected." -msgstr "La demande de rencontre de %(player)s a été refusée." - -#: app/routes/users.py:1286 +#: app/routes/users/notes.py:34 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users.py:1328 -msgid "Only coaches can manage availability." -msgstr "Seuls les coachs peuvent gérer leurs disponibilités." - -#: app/routes/users.py:1394 +#: app/routes/users/notes.py:71 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users.py:1484 +#: app/routes/users/notes.py:161 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users.py:1490 +#: app/routes/users/notes.py:167 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users.py:1503 +#: app/routes/users/notes.py:180 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users.py:1518 +#: app/routes/users/notes.py:195 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users.py:1525 app/routes/users.py:1568 app/routes/users.py:1619 -#: app/routes/users.py:1673 +#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 +#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 msgid "Player and content are required." msgstr "Le joueur et le contenu sont obligatoires." -#: app/routes/users.py:1534 app/routes/users.py:1577 app/routes/users.py:1623 -#: app/routes/users.py:1677 +#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 +#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users.py:1544 app/routes/users.py:1590 +#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users.py:1558 app/routes/users.py:1604 app/routes/users.py:1657 +#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 +#: app/routes/users/notes.py:334 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users.py:1634 app/routes/users.py:1688 +#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 msgid "Note added successfully." msgstr "Note ajoutée." +#: app/routes/users/one_on_one.py:20 +msgid "Only players can request One on One sessions." +msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." + +#: app/routes/users/one_on_one.py:33 +msgid "You do not have a coach assigned to your team." +msgstr "Aucun coach n’est assigné à votre équipe." + +#: app/routes/users/one_on_one.py:68 +msgid "Cannot request One on One - no coach assigned." +msgstr "Impossible de demander une rencontre : aucun coach assigné." + +#: app/routes/users/one_on_one.py:76 +msgid "Invalid date or time format." +msgstr "Format de date ou d’heure invalide." + +#: app/routes/users/one_on_one.py:90 +msgid "The requested time is not within the coach's availability." +msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." + +#: app/routes/users/one_on_one.py:118 +msgid "Your One on One request has been submitted!" +msgstr "Votre demande de rencontre a été envoyée." + +#: app/routes/users/one_on_one.py:163 +msgid "Only coaches can accept One on One requests." +msgstr "Seuls les coachs peuvent accepter une demande de rencontre." + +#: app/routes/users/one_on_one.py:169 app/routes/users/one_on_one.py:219 +msgid "This request is not for you." +msgstr "Cette demande ne vous est pas destinée." + +#: app/routes/users/one_on_one.py:173 app/routes/users/one_on_one.py:223 +msgid "This request has already been processed." +msgstr "Cette demande a déjà été traitée." + +#: app/routes/users/one_on_one.py:200 +#, python-format +msgid "One on One request from %(player)s has been approved!" +msgstr "La demande de rencontre de %(player)s a été approuvée." + +#: app/routes/users/one_on_one.py:213 +msgid "Only coaches can reject One on One requests." +msgstr "Seuls les coachs peuvent refuser une demande de rencontre." + +#: app/routes/users/one_on_one.py:255 +#, python-format +msgid "One on One request from %(player)s has been rejected." +msgstr "La demande de rencontre de %(player)s a été refusée." + +#: app/routes/users/profile.py:83 +msgid "Username already taken." +msgstr "Ce nom d’utilisateur est déjà pris." + +#: app/routes/users/profile.py:93 +msgid "Email already in use." +msgstr "Cette adresse courriel est déjà utilisée." + +#: app/routes/users/profile.py:123 +msgid "Profile updated successfully!" +msgstr "Profil mis à jour." + #: app/templates/errors/400.html:2 msgid "400 Bad Request" msgstr "400 Requête incorrecte" @@ -2387,11 +2389,7 @@ msgstr "Confirmer le mot de passe" msgid "Confirm your password" msgstr "Confirmez votre mot de passe" -#: app/templates/pages/register.html:151 -msgid "Answer" -msgstr "Réponse" - -#: app/templates/pages/register.html:154 +#: app/templates/pages/register.html:159 msgid "Create Account" msgstr "Créer le compte" @@ -2900,3 +2898,9 @@ msgstr "Voir le profil" #~ "%(remaining)s tentative(s) avant le " #~ "verrouillage." +#~ msgid "Incorrect CAPTCHA answer. Please try again." +#~ msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." + +#~ msgid "Answer" +#~ msgstr "Réponse" + diff --git a/tests/test_registration_screening.py b/tests/test_registration_screening.py new file mode 100644 index 0000000..472d9e3 --- /dev/null +++ b/tests/test_registration_screening.py @@ -0,0 +1,217 @@ +"""What now stands between a robot and a new account (SEC-AUTH-008). + +The arithmetic CAPTCHA it replaces had nineteen possible answers and could +be solved by reading the question as a string. It stopped nothing, cost every +human a step, and — being counted as a protection — was worse than nothing. + +Two checks took its place: a honeypot input, and a floor on how fast the +form can come back. Both are invisible to a person filling in the form. Both +are honest about their ceiling: they stop commodity spam, not somebody who +reads the page. + +The tests that matter most here are the ones asserting a *legitimate* +sign-up still works. A screening rule that quietly refuses real people is a +worse outcome than the CAPTCHA was. +""" + +import time + +from app.routes.auth import ( + MIN_REGISTRATION_SECONDS, + REGISTRATION_HONEYPOT_FIELD, + REGISTRATION_ISSUED_KEY, +) + +FORM = { + 'username': 'brandnew', + 'email': 'brandnew@example.test', + 'password': 'Password123', + 'confirm_password': 'Password123', + 'full_name': 'Brand New', +} + + +def _issued_long_ago(client, seconds_ago=None): + """Pretend the form was handed out a while back. + + Backdated rather than slept through: waiting three real seconds per test + would add a minute to the suite for nothing. + """ + if seconds_ago is None: + seconds_ago = MIN_REGISTRATION_SECONDS + 1 + with client.session_transaction() as session: + session[REGISTRATION_ISSUED_KEY] = time.time() - seconds_ago + + +def _account_exists(app, username='brandnew'): + from app.models import User + + with app.app_context(): + return User.query.filter_by(username=username).first() is not None + + +class TestLegitimateSignUp: + def test_a_person_filling_the_form_gets_an_account(self, app, client): + client.get('/auth/register') + _issued_long_ago(client) + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert _account_exists(app) + + def test_an_empty_honeypot_is_not_a_refusal(self, app, client): + """A browser submits the hidden input as an empty string, not absent.""" + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: ''}), + follow_redirects=True, + ) + + assert _account_exists(app) + + def test_correcting_a_typo_does_not_restart_the_clock(self, app, client): + """The dwell timer must survive a failed attempt. + + Reissuing it on every re-render would refuse the second submission of + anyone who fixes a mistake quickly — a rule that fires on real people + and not on robots, which is the wrong way round. + """ + client.get('/auth/register') + _issued_long_ago(client) + + # First attempt fails validation: passwords do not match. + client.post( + '/auth/register', + data=dict(FORM, confirm_password='Different123'), + follow_redirects=True, + ) + # Corrected and sent straight back, well inside the dwell floor. + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert _account_exists(app) + + +class TestScreening: + def test_a_filled_honeypot_is_refused(self, app, client): + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'http://spam.example'}), + follow_redirects=True, + ) + + assert not _account_exists(app) + + def test_a_form_returned_instantly_is_refused(self, app, client): + client.get('/auth/register') + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert not _account_exists(app) + + def test_a_post_that_never_fetched_the_form_is_refused(self, app, client): + """The strongest signal available: no form was ever issued.""" + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert not _account_exists(app) + + def test_the_two_rules_are_indistinguishable_to_the_sender(self, app, client): + """Naming the rule tells whoever tripped it how to avoid it. + + Both refusals must read identically from outside. The log, which the + sender cannot see, is where they are told apart. + + One client per scenario, on purpose: the dwell stamp lives in the + session and is deliberately kept across a refusal, so reusing a single + client would carry the first scenario's backdated stamp into the + second and the "too fast" case would never fire. + """ + _issued_long_ago(client) + tripped_honeypot = client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'spam'}), + follow_redirects=True, + ).get_data(as_text=True) + + fresh = app.test_client() + fresh.get('/auth/register') + too_fast = fresh.post('/auth/register', data=dict(FORM), follow_redirects=True).get_data( + as_text=True + ) + + def flashes(page): + return [line for line in page.splitlines() if 'alert-danger' in line] + + assert flashes(tripped_honeypot) == flashes(too_fast) + assert flashes(tripped_honeypot), 'no message at all is not the same as a uniform one' + + +class TestTheFormItself: + def test_no_arithmetic_question_is_asked(self, client): + page = client.get('/auth/register').get_data(as_text=True) + + assert 'captcha' not in page.lower() + + def test_the_honeypot_is_hidden_from_assistive_technology(self, client): + """An input a screen reader announces is a trap for a person. + + aria-hidden, tabindex=-1 and display:none all have to hold. The CSS + rule lives in the stylesheet rather than in a style attribute so that + it survives a future tightening of style-src. + """ + page = client.get('/auth/register').get_data(as_text=True) + + assert 'class="honeypot" aria-hidden="true"' in page + assert f'name="{REGISTRATION_HONEYPOT_FIELD}" tabindex="-1"' in page + + def test_the_stylesheet_actually_hides_it(self, app): + import os + + with open(os.path.join(app.static_folder, 'css', 'style.css'), encoding='utf-8') as handle: + css = handle.read() + + block = css.split('.honeypot')[-1] + assert 'display: none' in block.split('}')[0] + + +class TestRefusalIsVisible: + def test_a_refusal_is_written_to_the_audit_log(self, app, client, monkeypatch): + """Sign-up abuse leaves a trace or it is not happening, as far as + anyone can tell.""" + recorded = [] + from app.routes import auth as auth_module + + monkeypatch.setattr( + auth_module, + 'log_auth_event', + lambda event, **fields: recorded.append((event, fields)), + ) + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'spam'}), + follow_redirects=True, + ) + + assert recorded, 'the double was never called — the test would pass on nothing' + assert recorded[0][0] == 'account.registration_refused' + assert recorded[0][1]['reason'] == 'honeypot' + + def test_the_reason_distinguishes_the_two_rules(self, app, client, monkeypatch): + recorded = [] + from app.routes import auth as auth_module + + monkeypatch.setattr( + auth_module, + 'log_auth_event', + lambda event, **fields: recorded.append((event, fields)), + ) + client.get('/auth/register') + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert recorded and recorded[0][1]['reason'] == 'too-fast' diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 921b1cb..9d7b84a 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -19,6 +19,8 @@ These tests state the guarantee, so that removing the rollback or reintroducing a mid-operation commit fails loudly. """ +import time + import pytest from app.models import User, UserGamertag @@ -78,9 +80,14 @@ class TestRegistrationIsOneOperation: } def _submit(self, client, app, **overrides): + # Registration is screened for robots (SEC-AUTH-008): the form has to + # have been issued, and long enough ago. Backdated here rather than + # slept through, so the suite does not pay three seconds per call. + from app.routes.auth import MIN_REGISTRATION_SECONDS, REGISTRATION_ISSUED_KEY + with client.session_transaction() as session: - session['captcha_answer'] = 4 - payload = dict(self.FORM, captcha_answer='4') + session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1 + payload = dict(self.FORM) payload.update(overrides) return client.post('/auth/register', data=payload, follow_redirects=True)