From 9647003c3f378916010ff29e46b739490a5f7ee0 Mon Sep 17 00:00:00 2001 From: GGThed Date: Sun, 16 Aug 2026 23:36:30 -0400 Subject: [PATCH] fix(auth): garder l identite Discord du cote verifie --- README.md | 7 +- app/routes/auth.py | 108 ++++++- app/routes/users/accounts.py | 10 + app/routes/users/profile.py | 2 - app/supporting_scripts/schema_report.py | 63 +++- app/templates/pages/edit_profile.html | 11 +- app/templates/pages/register.html | 2 - app/translations/en/LC_MESSAGES/messages.mo | Bin 45460 -> 45760 bytes app/translations/en/LC_MESSAGES/messages.po | 311 ++++++++++--------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 49787 -> 50084 bytes app/translations/fr/LC_MESSAGES/messages.po | 315 +++++++++++--------- app/validators.py | 11 - docs/database-schema.md | 17 +- tests/test_discord_identity.py | 222 ++++++++++++++ tests/test_i18n.py | 61 ++++ tests/test_schema_report.py | 33 ++ 16 files changed, 833 insertions(+), 340 deletions(-) create mode 100644 tests/test_discord_identity.py diff --git a/README.md b/README.md index 6fff8c0..01036bd 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,11 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie : - **Les comptes créés par le formulaire d'inscription sont actifs immédiatement** : il n'y a pas d'étape de validation par le staff. Décision de produit en attente, voir `docs/roles-and-permissions.md`. -- **L'identité Discord** transite encore par un champ caché du formulaire - d'inscription : elle n'est pas prouvée par le passage OAuth2. +- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité + OAuth reste désormais côté serveur, le profil ne peut plus réécrire le + snowflake et l'application refuse les nouvelles collisions. Les doublons + historiques doivent être relevés puis corrigés avant la contrainte + `UNIQUE` (`schema_report.py --check-discord-identities`). `docs/security-checklist.md` détaille la liste avant mise en production. diff --git a/app/routes/auth.py b/app/routes/auth.py index 0bff41d..d74a68d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -21,10 +21,14 @@ from app.extensions import check_password, db, hash_password, limiter from app.i18n import LOCALE_SESSION_KEY from app.logging_config import log_auth_event from app.models import ESPORT_GAMES, Player, User -from app.validators import LoginSchema, RegisterSchema +from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id #: Session key holding the pending OAuth2 anti-forgery token. DISCORD_STATE_KEY = 'discord_oauth_state' +#: Whether the OAuth result should create a registration draft or relink the +#: signed-in account. Kept server-side and covered by the same signed session +#: as the anti-forgery state. +DISCORD_PURPOSE_KEY = 'discord_oauth_purpose' # Failed-attempt tracking. The tally is kept for the audit trail and for the # cool-off marker below; it no longer refuses a correct password (SEC-018). @@ -354,6 +358,15 @@ def register(): form_data = dict(request.form) form_data['games'] = request.form.getlist('games') + # Once Discord has authenticated the identity, neither its display + # name nor its snowflake is input data anymore. Remove any client + # copies before validation as well as before persistence: otherwise a + # forged, malformed hidden value can still make the verified flow fail. + discord_oauth = session.get('discord_oauth') or {} + if discord_oauth.get('id'): + form_data.pop('discord_username', None) + form_data.pop('discord_user_id', None) + refusal = check_registration_challenge(request.form) if refusal is not None: # Logged, because this is the only place abuse of the sign-up @@ -380,8 +393,18 @@ def register(): full_name = validated['full_name'] phone = validated.get('phone') selected_games = validated.get('games', []) - discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') + # The OAuth identity is server-side state. It used to be copied into + # hidden inputs and read back from request.form, which let anyone + # replace the verified Discord account before submitting (SEC-AUTH-005). + # A manual registration may still provide a display name, but never a + # Discord snowflake: that identifier is an authentication factor for + # bot reactions and must come from Discord itself. + discord_user_id = discord_oauth.get('id') + if discord_user_id: + discord_user_id = str(discord_user_id) + discord_username = ( + discord_oauth.get('username') if discord_user_id else validated.get('discord_username') + ) league_os_profile = validated.get('league_os_profile') if User.query.filter_by(username=username).first(): @@ -392,6 +415,13 @@ def register(): flash(_('Email already registered.'), 'danger') return _rerender_registration(form_data) + # The database constraint belongs to DB-002, after production has + # been backed up and deduplicated. Refuse new duplicates now instead + # of leaving the critical impersonation path open until then. + if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first(): + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender_registration(form_data) + hashed_password = hash_password(password) user = Player( username=username, @@ -451,11 +481,15 @@ def discord_login(): Returns: Response: Redirect to Discord authorization URL. """ + purpose = 'profile' if current_user.is_authenticated else 'registration' + session[DISCORD_PURPOSE_KEY] = purpose + return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register' + # 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') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Anti-forgery token, required by RFC 6749 §10.12. Without it, an # attacker could have the victim's browser consume an authorization code @@ -479,16 +513,24 @@ def discord_login(): def discord_callback(): """Handle the OAuth2 callback from Discord. - Exchanges the authorization code for an access token, then fetches - the user's profile (/users/@me) and connections (/users/@me/connections). - Results are stored in the session and the user is redirected back to - the registration form where fields will be pre-filled. + Exchanges the authorization code for an access token, then fetches the + user's profile. During registration, connected game accounts are also + loaded into server-side draft state. For a signed-in profile relink, the + verified identity is written directly without passing through a form. Returns: - Response: Redirect to registration page. + Response: Redirect to the registration form or profile editor. """ # The state is consumed whatever happens next: a token is single-use, and # leaving it in the session would allow a replay. + purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration') + if purpose == 'profile' and current_user.is_authenticated: + return_endpoint = 'users.edit_profile' + elif purpose == 'profile': + return_endpoint = 'auth.login' + else: + return_endpoint = 'auth.register' + expected_state = session.pop(DISCORD_STATE_KEY, None) received_state = request.args.get('state', '') @@ -500,12 +542,12 @@ def discord_callback(): ), 'danger', ) - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) code = request.args.get('code') if not code: flash(_('Discord authorization failed. No code received.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Exchange the authorization code for an access token token_data = { @@ -529,11 +571,11 @@ def discord_callback(): access_token = token_json.get('access_token') except requests.RequestException: flash(_('Failed to connect to Discord. Please try again.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) if not access_token: flash(_('Failed to obtain Discord access token.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) auth_headers = {'Authorization': f'Bearer {access_token}'} @@ -548,7 +590,43 @@ def discord_callback(): user_data = user_response.json() except requests.RequestException: flash(_('Failed to fetch Discord user profile.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) + + discord_user_id = user_data.get('id') + try: + if not discord_user_id: + raise ValidationError('missing Discord user id') + discord_user_id = str(discord_user_id) + validate_discord_user_id(discord_user_id) + except ValidationError: + flash(_('Failed to fetch Discord user profile.'), 'danger') + return redirect(url_for(return_endpoint)) + + if purpose == 'profile': + # If the session expired while Discord was open, do not turn a profile + # relink into registration state for an anonymous browser. + if not current_user.is_authenticated: + flash(_('Please log in to connect your Discord account.'), 'danger') + return redirect(url_for('auth.login')) + + clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != current_user.id, + ).first() + if clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return redirect(url_for('users.edit_profile')) + + current_user.discord_user_id = discord_user_id + current_user.discord_username = user_data.get('username') or None + db.session.commit() + log_auth_event( + 'account.discord_linked', + username=current_user.username, + user_id=current_user.id, + ) + flash(_('Discord account connected!'), 'success') + return redirect(url_for('users.edit_profile')) # Fetch the user's connected gaming accounts connections = [] @@ -587,7 +665,7 @@ def discord_callback(): # Store in session for the registration form to use session['discord_oauth'] = { - 'id': user_data.get('id'), + 'id': discord_user_id, 'username': user_data.get('username'), 'avatar': user_data.get('avatar'), 'gamertag_suggestions': gamertag_suggestions, diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index 7942525..ef39cd8 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -111,6 +111,16 @@ def edit_user(user_id): flash(_('Email already in use by another account.'), 'danger') return _rerender() + discord_clash = None + if discord_user_id: + discord_clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != user.id, + ).first() + if discord_clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender() + role_changed = user.role != role previous_role = user.role diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 2898431..39cfeda 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -76,7 +76,6 @@ def edit_profile(): phone = validated.get('phone') selected_games = validated.get('games', []) discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') league_os_profile = validated.get('league_os_profile') if username != current_user.username and User.query.filter_by(username=username).first(): @@ -105,7 +104,6 @@ def edit_profile(): current_user.phone = phone current_user.games = ','.join(selected_games) if selected_games else None current_user.discord_username = discord_username or None - current_user.discord_user_id = discord_user_id or None current_user.league_os_profile = league_os_profile or None update_user_gamertags(current_user, selected_games) diff --git a/app/supporting_scripts/schema_report.py b/app/supporting_scripts/schema_report.py index 7942786..d9952f2 100644 --- a/app/supporting_scripts/schema_report.py +++ b/app/supporting_scripts/schema_report.py @@ -29,10 +29,13 @@ Usage # Also look for the seeded admin/password account (SEC-003) python app/supporting_scripts/schema_report.py --check-seed-accounts + # Find Discord identities that must be reconciled before UNIQUE (SEC-012) + python app/supporting_scripts/schema_report.py --check-discord-identities + Exit codes ---------- 0 the live schema matches the models - 1 drift found — the report says what + 1 drift or requested data risk found — the report says what 2 could not connect or read the catalogue Reading the output @@ -315,6 +318,42 @@ def find_seed_accounts(engine): return results +def find_duplicate_discord_identities(engine): + """Discord snowflakes claimed by more than one account (SEC-012). + + New links are now refused in application code, but existing production + rows predate that guard. These groups must be reconciled before Alembic + can add the database-level UNIQUE constraint. + + Returns: + list[tuple]: (discord_user_id, comma-separated usernames, count). + """ + from sqlalchemy import text + + with engine.connect() as connection: + rows = connection.execute( + text( + 'SELECT discord_user_id, COUNT(*) AS account_count ' + 'FROM users ' + "WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' " + 'GROUP BY discord_user_id HAVING COUNT(*) > 1 ' + 'ORDER BY discord_user_id' + ) + ).fetchall() + + duplicates = [] + for discord_user_id, account_count in rows: + usernames = connection.execute( + text( + 'SELECT username FROM users ' + 'WHERE discord_user_id = :discord_user_id ORDER BY username' + ), + {'discord_user_id': discord_user_id}, + ).scalars() + duplicates.append((discord_user_id, ', '.join(usernames), account_count)) + return duplicates + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument( @@ -327,6 +366,11 @@ def main(argv=None): action='store_true', help='Also look for the admin/password account seeded by clear_db.py (SEC-003).', ) + parser.add_argument( + '--check-discord-identities', + action='store_true', + help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.', + ) args = parser.parse_args(argv) if not args.url: @@ -375,9 +419,24 @@ def main(argv=None): ) print(f' {username} ({role}): {verdict}') + duplicate_discord_identities = [] + if args.check_discord_identities: + print('\n' + '=' * 78) + print('Duplicate Discord identities (SEC-012)') + print('=' * 78) + try: + duplicate_discord_identities = find_duplicate_discord_identities(engine) + except SQLAlchemyError as exc: + print(f'Could not check: {exc}') + else: + if not duplicate_discord_identities: + print('No Discord identity is shared by multiple accounts.') + for discord_user_id, usernames, account_count in duplicate_discord_identities: + print(f' {discord_user_id}: {account_count} accounts ({usernames})') + blocking = sum(1 for f in findings if f.severity == BLOCKING) print(f'\n{len(findings)} finding(s), {blocking} blocking.') - return 1 if findings else 0 + return 1 if findings or duplicate_discord_identities else 0 if __name__ == '__main__': # pragma: no cover diff --git a/app/templates/pages/edit_profile.html b/app/templates/pages/edit_profile.html index c9977e7..656970c 100644 --- a/app/templates/pages/edit_profile.html +++ b/app/templates/pages/edit_profile.html @@ -79,14 +79,13 @@
-
+
-
-
- - - {{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }} + + + {% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %} +
diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index 6bcc23d..b1a90c1 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -57,8 +57,6 @@ {{ _('Reconnect') }}
- - {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }} diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 23c3123b5ef362bd05882722c601ce3d82729c27..8fc3943a946fc204fe799869613b37189ae7fc6d 100644 GIT binary patch delta 10851 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 LgZ~>sI#KsO7oy6q delta 10681 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\n" "Language: en\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Please log in to access this page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 +#: app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" @@ -61,7 +61,7 @@ msgstr "Username is required." msgid "Password is required." msgstr "Password is required." -#: app/validators.py:227 app/validators.py:299 +#: app/validators.py:227 app/validators.py:294 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." @@ -69,169 +69,169 @@ msgstr "Username must be 3-80 characters." msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:309 app/validators.py:340 +#: app/validators.py:403 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:318 app/validators.py:353 +#: app/validators.py:313 app/validators.py:348 msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:454 +#: app/validators.py:443 msgid "Player must be selected." msgstr "Player must be selected." -#: app/validators.py:457 +#: app/validators.py:446 msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:494 app/validators.py:853 +#: app/validators.py:483 app/validators.py:854 msgid "Invalid coach selection." msgstr "Invalid coach selection." -#: app/validators.py:500 app/validators.py:859 +#: app/validators.py:489 app/validators.py:860 msgid "Invalid manager selection." msgstr "Invalid manager selection." -#: app/validators.py:511 +#: app/validators.py:507 msgid "Invalid player selection." msgstr "Invalid player selection." -#: app/validators.py:515 +#: app/validators.py:516 msgid "Unknown roster status." msgstr "Unknown roster status." -#: app/validators.py:538 +#: app/validators.py:539 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:539 +#: app/validators.py:540 msgid "A date is required." msgstr "A date is required." -#: app/validators.py:545 app/validators.py:610 +#: app/validators.py:546 app/validators.py:611 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:546 app/validators.py:611 +#: app/validators.py:547 app/validators.py:612 msgid "A start time is required." msgstr "A start time is required." -#: app/validators.py:552 +#: app/validators.py:553 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:553 +#: app/validators.py:554 msgid "An end time is required." msgstr "An end time is required." -#: app/validators.py:557 +#: app/validators.py:558 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:572 +#: app/validators.py:573 msgid "End time must be after start time." msgstr "End time must be after start time." -#: app/validators.py:601 app/validators.py:603 +#: app/validators.py:602 app/validators.py:604 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/validators.py:604 +#: app/validators.py:605 msgid "A day is required." msgstr "A day is required." -#: app/validators.py:639 +#: app/validators.py:640 msgid "Player selection is malformed." msgstr "Player selection is malformed." -#: app/validators.py:665 app/validators.py:775 +#: app/validators.py:666 app/validators.py:776 msgid "A title is required." msgstr "A title is required." -#: app/validators.py:674 +#: app/validators.py:675 msgid "Invalid date format." msgstr "Invalid date format." -#: app/validators.py:679 app/validators.py:686 +#: app/validators.py:680 app/validators.py:687 msgid "Invalid time format." msgstr "Invalid time format." -#: app/validators.py:680 +#: app/validators.py:681 msgid "Start time is required. Please select a time slot." msgstr "Start time is required. Please select a time slot." -#: app/validators.py:694 +#: app/validators.py:695 msgid "Unknown match status." msgstr "Unknown match status." -#: app/validators.py:710 +#: app/validators.py:711 msgid "The end time must come after the start time." msgstr "The end time must come after the start time." -#: app/validators.py:724 +#: app/validators.py:725 msgid "Unknown match type." msgstr "Unknown match type." -#: app/validators.py:736 +#: app/validators.py:737 msgid "A team cannot play against itself." msgstr "A team cannot play against itself." -#: app/validators.py:784 +#: app/validators.py:785 msgid "Unknown game." msgstr "Unknown game." -#: app/validators.py:789 +#: app/validators.py:790 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/validators.py:790 +#: app/validators.py:791 msgid "A start date is required." msgstr "A start date is required." -#: app/validators.py:796 +#: app/validators.py:797 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/validators.py:804 +#: app/validators.py:805 msgid "A tryout must allow at least one player." msgstr "A tryout must allow at least one player." -#: app/validators.py:807 +#: app/validators.py:808 msgid "The player limit must be a whole number." msgstr "The player limit must be a whole number." -#: app/validators.py:819 +#: app/validators.py:820 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/validators.py:846 app/validators.py:847 +#: app/validators.py:847 app/validators.py:848 msgid "Team name is required." msgstr "Team name is required." -#: app/validators.py:871 +#: app/validators.py:872 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:872 +#: app/validators.py:873 msgid "A score must be a whole number from 1 to 10." msgstr "A score must be a whole number from 1 to 10." -#: app/routes/auth.py:241 +#: app/routes/auth.py:245 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:276 +#: app/routes/auth.py:280 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:306 +#: app/routes/auth.py:310 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -239,27 +239,32 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:363 +#: app/routes/auth.py:376 msgid "Your registration could not be processed. Please try again." msgstr "Your registration could not be processed. Please try again." -#: app/routes/auth.py:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:411 app/routes/users/accounts.py:339 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: app/routes/auth.py:415 app/routes/users/accounts.py:343 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:436 +#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/users/accounts.py:121 +msgid "This Discord account is already linked to another account." +msgstr "This Discord account is already linked to another account." + +#: app/routes/auth.py:466 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:457 +#: app/routes/auth.py:491 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:498 +#: app/routes/auth.py:540 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -267,27 +272,35 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:549 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:531 +#: app/routes/auth.py:573 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:535 +#: app/routes/auth.py:577 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:550 +#: app/routes/auth.py:592 app/routes/auth.py:602 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:597 +#: app/routes/auth.py:609 +msgid "Please log in to connect your Discord account." +msgstr "Please log in to connect your Discord account." + +#: app/routes/auth.py:628 +msgid "Discord account connected!" +msgstr "Discord account connected!" + +#: app/routes/auth.py:675 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:625 +#: app/routes/auth.py:703 msgid "You have been logged out." msgstr "You have been logged out." @@ -323,8 +336,8 @@ msgstr "Evaluation updated!" #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 #: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 #: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 +#: app/routes/tryouts.py:582 msgid "Permission denied." msgstr "Permission denied." @@ -463,7 +476,7 @@ msgstr "Coach removed from %(name)s." msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 msgid "Please select a player." msgstr "Please select a player." @@ -545,7 +558,7 @@ msgstr "This tryout is not accepting registrations." msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 msgid "This tryout is full." msgstr "This tryout is full." @@ -562,47 +575,47 @@ msgstr "Tryout status updated to %(new_status)s." msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:489 +#: app/routes/tryouts.py:497 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:503 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:509 +#: app/routes/tryouts.py:517 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:553 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:563 +#: app/routes/tryouts.py:571 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:594 +#: app/routes/tryouts.py:602 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:600 +#: app/routes/tryouts.py:608 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:605 +#: app/routes/tryouts.py:613 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:615 +#: app/routes/tryouts.py:623 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:651 +#: app/routes/tryouts.py:659 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." @@ -630,11 +643,11 @@ msgstr "Only the president can edit users." msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users/accounts.py:122 +#: app/routes/users/accounts.py:132 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/accounts.py:135 +#: app/routes/users/accounts.py:145 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -642,29 +655,29 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users/accounts.py:214 +#: app/routes/users/accounts.py:224 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users/accounts.py:235 +#: app/routes/users/accounts.py:245 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users/accounts.py:239 +#: app/routes/users/accounts.py:249 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users/accounts.py:298 +#: app/routes/users/accounts.py:308 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users/accounts.py:309 +#: app/routes/users/accounts.py:319 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users/accounts.py:357 +#: app/routes/users/accounts.py:367 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" @@ -673,32 +686,32 @@ msgstr "User %(full_name)s created as %(role)s!" msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users/contracts.py:103 +#: app/routes/users/contracts.py:105 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/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "No signed contract available." @@ -796,15 +809,15 @@ msgstr "Only coaches can reject One on One requests." 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 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:121 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" @@ -953,7 +966,7 @@ msgstr "%(total)s in total" msgid "Next" msgstr "Next" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" @@ -995,38 +1008,34 @@ msgid "My Notes" msgstr "My Notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Availability" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes & One on One" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contracts" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Team Tryout Management System" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1209,6 +1218,10 @@ msgid "Loading availability grid..." msgstr "Loading availability grid..." #: app/templates/pages/coach_availability.html:19 +msgid "Save Availability" +msgstr "Save Availability" + +#: app/templates/pages/coach_availability.html:22 #: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 msgid "Clear All" msgstr "Clear All" @@ -1372,7 +1385,7 @@ msgid "Role" msgstr "Role" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Password" @@ -1535,7 +1548,7 @@ msgstr "Edit Profile" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "E-Sports Profile" @@ -1544,12 +1557,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Update your competitive gaming profile for tryouts." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Games You Play" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Select all games you're signing in for." @@ -1593,48 +1606,43 @@ msgid "e.g. Name#1234" msgstr "e.g. Name#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(for DMs)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnect" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Numeric ID (e.g. 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connect Discord Account" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "League OS Connection" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "League OS profile link or ID" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Change Password" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Leave blank to keep your current password." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "New Password" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Enter new password" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Save Changes" @@ -1650,6 +1658,18 @@ msgstr "Games" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Enter gamertag for each selected game to link to Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(for DMs)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Numeric ID (e.g. 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(leave blank to keep current)" @@ -2365,7 +2385,7 @@ msgstr "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:460 msgid "Click or click-and-drag to select your available hours" msgstr "Click or click-and-drag to select your available hours" @@ -2413,11 +2433,7 @@ msgstr "" msgid "Connected" msgstr "Connected" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnect" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2426,50 +2442,46 @@ msgstr "" "profile below." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connect Discord Account" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Discord Username (Manual)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "e.g. YourName" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Set up your competitive gaming profile for tryouts." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "League OS Connection (Optional)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Connect your League OS profile for organized play." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Security" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Create a password" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirm Password" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirm your password" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Create Account" @@ -2984,3 +2996,8 @@ msgstr "View Profile" #~ msgid "Invalid date or time format." #~ msgstr "Invalid date or time format." +#~ msgid "Availability" +#~ msgstr "Availability" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Team Tryout Management System" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index b9f3180a3f7382439cb68f64561b4ec9a91a504d..bc11174bc224b8470f7d42f75cd45fff6a099738 100644 GIT binary patch delta 10871 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 delta 10703 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 RU|(<8n_v2R@9*Cc@_*!j(NzEd diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 690e0cc..ea43343 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 20:39-0400\n" +"POT-Creation-Date: 2026-08-16 23:22-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 +#: app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -63,7 +63,7 @@ msgstr "Le nom d’utilisateur est obligatoire." msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:227 app/validators.py:299 +#: app/validators.py:227 app/validators.py:294 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." @@ -71,169 +71,169 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:309 app/validators.py:340 +#: app/validators.py:403 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:318 app/validators.py:353 +#: app/validators.py:313 app/validators.py:348 msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:454 +#: app/validators.py:443 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." -#: app/validators.py:457 +#: app/validators.py:446 msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:494 app/validators.py:853 +#: app/validators.py:483 app/validators.py:854 msgid "Invalid coach selection." msgstr "Sélection de coach invalide." -#: app/validators.py:500 app/validators.py:859 +#: app/validators.py:489 app/validators.py:860 msgid "Invalid manager selection." msgstr "Sélection de gérant invalide." -#: app/validators.py:511 +#: app/validators.py:507 msgid "Invalid player selection." msgstr "Sélection de joueur invalide." -#: app/validators.py:515 +#: app/validators.py:516 msgid "Unknown roster status." msgstr "Statut d'effectif inconnu." -#: app/validators.py:538 +#: app/validators.py:539 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:539 +#: app/validators.py:540 msgid "A date is required." msgstr "Une date est requise." -#: app/validators.py:545 app/validators.py:610 +#: app/validators.py:546 app/validators.py:611 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:546 app/validators.py:611 +#: app/validators.py:547 app/validators.py:612 msgid "A start time is required." msgstr "Une heure de début est requise." -#: app/validators.py:552 +#: app/validators.py:553 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:553 +#: app/validators.py:554 msgid "An end time is required." msgstr "Une heure de fin est requise." -#: app/validators.py:557 +#: app/validators.py:558 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:572 +#: app/validators.py:573 msgid "End time must be after start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:601 app/validators.py:603 +#: app/validators.py:602 app/validators.py:604 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/validators.py:604 +#: app/validators.py:605 msgid "A day is required." msgstr "Un jour est requis." -#: app/validators.py:639 +#: app/validators.py:640 msgid "Player selection is malformed." msgstr "La sélection de joueurs est mal formée." -#: app/validators.py:665 app/validators.py:775 +#: app/validators.py:666 app/validators.py:776 msgid "A title is required." msgstr "Un titre est requis." -#: app/validators.py:674 +#: app/validators.py:675 msgid "Invalid date format." msgstr "Format de date invalide." -#: app/validators.py:679 app/validators.py:686 +#: app/validators.py:680 app/validators.py:687 msgid "Invalid time format." msgstr "Format d’heure invalide." -#: app/validators.py:680 +#: app/validators.py:681 msgid "Start time is required. Please select a time slot." msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." -#: app/validators.py:694 +#: app/validators.py:695 msgid "Unknown match status." msgstr "Statut de match inconnu." -#: app/validators.py:710 +#: app/validators.py:711 msgid "The end time must come after the start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:724 +#: app/validators.py:725 msgid "Unknown match type." msgstr "Type de match inconnu." -#: app/validators.py:736 +#: app/validators.py:737 msgid "A team cannot play against itself." msgstr "Une équipe ne peut pas jouer contre elle-même." -#: app/validators.py:784 +#: app/validators.py:785 msgid "Unknown game." msgstr "Jeu inconnu." -#: app/validators.py:789 +#: app/validators.py:790 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/validators.py:790 +#: app/validators.py:791 msgid "A start date is required." msgstr "Une date de début est requise." -#: app/validators.py:796 +#: app/validators.py:797 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/validators.py:804 +#: app/validators.py:805 msgid "A tryout must allow at least one player." msgstr "Une sélection doit accepter au moins un joueur." -#: app/validators.py:807 +#: app/validators.py:808 msgid "The player limit must be a whole number." msgstr "La limite de joueurs doit être un nombre entier." -#: app/validators.py:819 +#: app/validators.py:820 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/validators.py:846 app/validators.py:847 +#: app/validators.py:847 app/validators.py:848 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/validators.py:871 +#: app/validators.py:872 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:872 +#: app/validators.py:873 msgid "A score must be a whole number from 1 to 10." msgstr "Une note doit être un nombre entier de 1 à 10." -#: app/routes/auth.py:241 +#: app/routes/auth.py:245 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:276 +#: app/routes/auth.py:280 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:306 +#: app/routes/auth.py:310 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -241,27 +241,32 @@ 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:363 +#: app/routes/auth.py:376 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:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:411 app/routes/users/accounts.py:339 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: app/routes/auth.py:415 app/routes/users/accounts.py:343 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:436 +#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/users/accounts.py:121 +msgid "This Discord account is already linked to another account." +msgstr "Ce compte Discord est déjà lié à un autre compte." + +#: app/routes/auth.py:466 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:457 +#: app/routes/auth.py:491 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:498 +#: app/routes/auth.py:540 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -269,27 +274,35 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:549 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:531 +#: app/routes/auth.py:573 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:535 +#: app/routes/auth.py:577 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:550 +#: app/routes/auth.py:592 app/routes/auth.py:602 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:597 +#: app/routes/auth.py:609 +msgid "Please log in to connect your Discord account." +msgstr "Veuillez vous connecter pour lier votre compte Discord." + +#: app/routes/auth.py:628 +msgid "Discord account connected!" +msgstr "Compte Discord connecté !" + +#: app/routes/auth.py:675 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:625 +#: app/routes/auth.py:703 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -325,8 +338,8 @@ msgstr "Évaluation mise à jour." #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 #: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 #: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 +#: app/routes/tryouts.py:582 msgid "Permission denied." msgstr "Accès refusé." @@ -467,7 +480,7 @@ msgstr "Coach retiré de %(name)s." msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 msgid "Please select a player." msgstr "Veuillez choisir un joueur." @@ -549,7 +562,7 @@ msgstr "Cette sélection n’accepte pas d’inscriptions." msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 msgid "This tryout is full." msgstr "Cette sélection est complète." @@ -566,47 +579,47 @@ msgstr "Statut de la sélection mis à jour : %(new_status)s." msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:489 +#: app/routes/tryouts.py:497 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:503 #, 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:509 +#: app/routes/tryouts.py:517 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:553 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:563 +#: app/routes/tryouts.py:571 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:594 +#: app/routes/tryouts.py:602 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:600 +#: app/routes/tryouts.py:608 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:605 +#: app/routes/tryouts.py:613 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:615 +#: app/routes/tryouts.py:623 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:651 +#: app/routes/tryouts.py:659 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." @@ -634,13 +647,13 @@ msgstr "Seul le président peut modifier des utilisateurs." msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users/accounts.py:122 +#: app/routes/users/accounts.py:132 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/accounts.py:135 +#: app/routes/users/accounts.py:145 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -648,29 +661,29 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users/accounts.py:214 +#: app/routes/users/accounts.py:224 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users/accounts.py:235 +#: app/routes/users/accounts.py:245 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users/accounts.py:239 +#: app/routes/users/accounts.py:249 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users/accounts.py:298 +#: app/routes/users/accounts.py:308 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users/accounts.py:309 +#: app/routes/users/accounts.py:319 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users/accounts.py:357 +#: app/routes/users/accounts.py:367 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." @@ -679,32 +692,32 @@ msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 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/contracts.py:103 +#: app/routes/users/contracts.py:105 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/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 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/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." @@ -804,15 +817,15 @@ msgstr "Seuls les coachs peuvent refuser une demande de rencontre." 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 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:121 msgid "Profile updated successfully!" msgstr "Profil mis à jour." @@ -959,7 +972,7 @@ msgstr "%(total)s au total" msgid "Next" msgstr "Suivant" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" @@ -1001,38 +1014,34 @@ msgid "My Notes" msgstr "Mes notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Disponibilités" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes et rencontres individuelles" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contrats" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Système de gestion des sélections d’équipe" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1217,6 +1226,10 @@ msgid "Loading availability grid..." msgstr "Chargement de la grille de disponibilités..." #: app/templates/pages/coach_availability.html:19 +msgid "Save Availability" +msgstr "Enregistrer les disponibilités" + +#: app/templates/pages/coach_availability.html:22 #: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 msgid "Clear All" msgstr "Tout effacer" @@ -1378,7 +1391,7 @@ msgid "Role" msgstr "Rôle" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Mot de passe" @@ -1541,7 +1554,7 @@ msgstr "Modifier le profil" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "Profil e-sport" @@ -1550,12 +1563,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Mettez à jour votre profil de joueur compétitif pour les sélections." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Jeux auxquels vous jouez" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Sélectionnez tous les jeux pour lesquels vous vous inscrivez." @@ -1599,50 +1612,43 @@ msgid "e.g. Name#1234" msgstr "ex. : Nom#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(pour les messages privés)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnecter" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Identifiant numérique (ex. : 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connecter un compte Discord" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "" -"Activez le mode développeur dans Discord → clic droit sur le profil → " -"Copier l’identifiant" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "Connexion League OS" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "Lien ou identifiant du profil League OS" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Changer le mot de passe" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Laissez vide pour conserver votre mot de passe actuel." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "Nouveau mot de passe" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Saisir le nouveau mot de passe" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Enregistrer les modifications" @@ -1658,6 +1664,20 @@ msgstr "Jeux" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Saisissez un pseudo par jeu sélectionné pour le lier à Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(pour les messages privés)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Identifiant numérique (ex. : 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "" +"Activez le mode développeur dans Discord → clic droit sur le profil → " +"Copier l’identifiant" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(laisser vide pour conserver l’actuel)" @@ -2378,7 +2398,7 @@ msgstr "" "Choisissez les plages où vous êtes disponible pour des rencontres " "individuelles (8 h à 22 h)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:460 msgid "Click or click-and-drag to select your available hours" msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité" @@ -2426,11 +2446,7 @@ msgstr "" msgid "Connected" msgstr "Connecté" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnecter" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2439,52 +2455,48 @@ msgstr "" "ci-dessous." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connecter un compte Discord" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "" "Connectez-vous pour pré-remplir vos pseudos depuis Steam, Battle.net, " "Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Nom d’utilisateur Discord (saisie manuelle)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "ex. : VotrePseudo" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Configurez votre profil de joueur compétitif pour les sélections." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "Connexion League OS (facultative)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Liez votre profil League OS pour le jeu organisé." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Sécurité" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Créez un mot de passe" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirmer le mot de passe" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirmez votre mot de passe" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Créer le compte" @@ -3008,3 +3020,8 @@ msgstr "Voir le profil" #~ msgid "Invalid date or time format." #~ msgstr "Format de date ou d’heure invalide." +#~ msgid "Availability" +#~ msgstr "Disponibilités" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Système de gestion des sélections d’équipe" diff --git a/app/validators.py b/app/validators.py index 7c25b24..b2502ca 100644 --- a/app/validators.py +++ b/app/validators.py @@ -256,11 +256,6 @@ class RegisterSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, @@ -392,7 +387,6 @@ class EditProfileSchema(StripMixin): phone: Optional. password: Optional (only if changing). discord_username: Optional. - discord_user_id: Optional. league_os_profile: Optional. games: Optional list. """ @@ -428,11 +422,6 @@ class EditProfileSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, diff --git a/docs/database-schema.md b/docs/database-schema.md index b65ff54..197b85d 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,8 +1,9 @@ # Le schéma réel, et comment sortir de `create_all()` -> **État au 2026-08-11** : l'outil de relevé existe et est testé. Le relevé -> lui-même n'a pas été exécuté — il demande un accès à la base de production, -> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution. +> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris +> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été +> exécuté — il demande un accès à la base de production, qui ne peut pas venir +> du dépôt. Tout changement de schéma ci-dessous attend cette exécution. ## Pourquoi c'est le nœud @@ -60,6 +61,14 @@ existe-t-il encore ? (`SEC-003`) : python app/supporting_scripts/schema_report.py --check-seed-accounts ``` +L'identité Discord est désormais conservée côté serveur et toute nouvelle +collision est refusée par l'application. Les doublons historiques restent à +identifier avant d'ajouter la contrainte `UNIQUE` de `SEC-012` : + +```bash +python app/supporting_scripts/schema_report.py --check-discord-identities +``` + ## Étape 3 — Alembic, décrivant le schéma **réel** (`DB-002`) Le piège de cette étape tient en une phrase : **la migration initiale doit @@ -111,7 +120,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` : | `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement | | `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 | | `ARCH-001` | Fusionner coach/équipe sur la relation m2m | Migration de données ; `app/permissions.py` rend la duplication inoffensive **en lecture** seulement, l'écriture crée toujours les deux | -| `SEC-012` | Identité Discord côté serveur, `unique=True` | La colonne doit être unique, donc dédoublonnée d'abord | +| `SEC-012` | Ajouter `unique=True` sur l'identité Discord | La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées d'abord | ## Ce qu'on ne fait pas diff --git a/tests/test_discord_identity.py b/tests/test_discord_identity.py new file mode 100644 index 0000000..eba679f --- /dev/null +++ b/tests/test_discord_identity.py @@ -0,0 +1,222 @@ +"""The Discord snowflake is a verified identity, not profile text. + +SEC-AUTH-005. Discord OAuth used to put its result in two hidden inputs; +registration then trusted those client-controlled values, and edit_profile +let the account owner replace the snowflake later. The bot uses that value to +route private messages and authorize reaction-driven writes. +""" + +import time +from urllib.parse import parse_qs, urlparse + +from app.routes import auth as auth_module +from app.routes.auth import ( + MIN_REGISTRATION_SECONDS, + REGISTRATION_ISSUED_KEY, +) + +FORM = { + 'username': 'brandnew', + 'email': 'brandnew@example.test', + 'password': 'Password123', + 'confirm_password': 'Password123', + 'full_name': 'Brand New', +} + + +def _allow_registration(client): + with client.session_transaction() as session: + session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1 + + +def _user(app, username='brandnew'): + from app.models import User + + with app.app_context(): + return User.query.filter_by(username=username).first() + + +class _DiscordResponse: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +def _complete_profile_oauth(client, monkeypatch, discord_user_id): + monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', 'client-id') + monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'client-secret') + monkeypatch.setattr( + auth_module, + 'DISCORD_REDIRECT_URI', + 'https://example.test/auth/discord/callback', + ) + start = client.get('/auth/discord/login') + state = parse_qs(urlparse(start.headers['Location']).query)['state'][0] + + monkeypatch.setattr( + auth_module.requests, + 'post', + lambda *args, **kwargs: _DiscordResponse({'access_token': 'token'}), + ) + monkeypatch.setattr( + auth_module.requests, + 'get', + lambda *args, **kwargs: _DiscordResponse( + {'id': discord_user_id, 'username': 'verified-user'} + ), + ) + return client.get(f'/auth/discord/callback?code=code&state={state}') + + +class TestRegistrationIdentity: + def test_a_posted_snowflake_without_oauth_is_ignored(self, app, client): + _allow_registration(client) + + client.post( + '/auth/register', + data=dict(FORM, discord_user_id='111111111111111111'), + ) + + assert _user(app).discord_user_id is None + + def test_the_server_side_oauth_identity_wins_over_the_form(self, app, client): + verified = '222222222222222222' + _allow_registration(client) + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': verified, + 'username': 'verified-user', + } + + client.post( + '/auth/register', + data=dict( + FORM, + discord_user_id='111111111111111111', + discord_username='forged-user', + ), + ) + + created = _user(app) + assert created.discord_user_id == verified + assert created.discord_username == 'verified-user' + + def test_an_oauth_identity_already_in_use_is_refused(self, app, client, make_user): + verified = '222222222222222222' + make_user('player', discord_user_id=verified) + _allow_registration(client) + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': verified, + 'username': 'verified-user', + } + + client.post('/auth/register', data=FORM) + + assert _user(app) is None + + def test_the_registration_page_has_no_client_identity_field(self, client): + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': '222222222222222222', + 'username': 'verified-user', + } + + page = client.get('/auth/register').get_data(as_text=True) + + assert 'name="discord_user_id"' not in page + + +class TestProfileIdentity: + def test_a_profile_post_cannot_replace_the_verified_snowflake(self, app, client, as_role): + original = '222222222222222222' + user_id = as_role('player', discord_user_id=original) + + client.post( + '/users/profile/edit', + data={ + 'username': 'player1', + 'full_name': 'Player 1', + 'email': 'player1@example.test', + 'discord_user_id': '111111111111111111', + }, + ) + + from app.models import User + + with app.app_context(): + assert ( + app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id == original + ) + + def test_the_profile_form_does_not_offer_the_snowflake(self, client, as_role): + as_role('player', discord_user_id='222222222222222222') + + page = client.get('/users/profile/edit').get_data(as_text=True) + + assert 'name="discord_user_id"' not in page + assert '/auth/discord/login' in page + + def test_a_signed_in_user_can_relink_only_through_oauth( + self, app, client, as_role, monkeypatch + ): + user_id = as_role('player', discord_user_id='111111111111111111') + + response = _complete_profile_oauth( + client, + monkeypatch, + discord_user_id='222222222222222222', + ) + + from app.models import User + + assert '/users/profile/edit' in response.headers['Location'] + with app.app_context(): + user = app.extensions['sqlalchemy'].session.get(User, user_id) + assert user.discord_user_id == '222222222222222222' + assert user.discord_username == 'verified-user' + + def test_profile_oauth_refuses_an_identity_owned_by_someone_else( + self, app, client, as_role, make_user, monkeypatch + ): + taken = '222222222222222222' + make_user('player', discord_user_id=taken) + user_id = as_role('player', discord_user_id='111111111111111111') + + _complete_profile_oauth(client, monkeypatch, discord_user_id=taken) + + from app.models import User + + with app.app_context(): + assert ( + app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id + == '111111111111111111' + ) + + +class TestAdministrativeFallback: + def test_an_admin_cannot_assign_a_snowflake_twice(self, app, client, as_role, make_user): + taken = '222222222222222222' + make_user('player', discord_user_id=taken) + target_id = make_user('player') + as_role('admin') + + client.post( + f'/users/{target_id}/edit', + data={ + 'full_name': 'Target Player', + 'email': 'target@example.test', + 'role': 'player', + 'discord_user_id': taken, + }, + ) + + from app.models import User + + with app.app_context(): + assert app.extensions['sqlalchemy'].session.get(User, target_id).discord_user_id is None diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 58c92a0..3d0dbf8 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -202,6 +202,67 @@ class TestCatalogueIntegrity: f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}' ) + # Babel keeps its guessed translation when it marks an entry fuzzy, + # but gettext deliberately ignores that guess at runtime. Merely + # checking m.string therefore let five English fallbacks through after + # the branding merge, including a dangerously wrong French label. + fuzzy = [m.id for m in catalog if m.id and 'fuzzy' in m.flags] + assert not fuzzy, f'{len(fuzzy)} fuzzy string(s) in {locale}: {fuzzy[:5]}' + + def test_the_catalogue_contains_every_message_in_the_source(self, tmp_path): + """A translated PO can still be stale. + + The previous guard only inspected entries already in the catalogue. + A new `_()` in Python or Jinja therefore stayed English without any + failure until somebody happened to run extraction by hand. + """ + import os + import subprocess + import sys + + from babel.messages.pofile import read_po + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + extracted_path = tmp_path / 'messages.pot' + subprocess.run( + [ + sys.executable, + '-m', + 'babel.messages.frontend', + 'extract', + '-F', + 'babel.cfg', + '-k', + '_l', + '-o', + str(extracted_path), + '.', + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + with open(extracted_path, encoding='utf-8') as handle: + source_ids = {message.id for message in read_po(handle) if message.id} + + for locale in SUPPORTED_LOCALES: + path = os.path.join( + root, + 'app', + 'translations', + locale, + 'LC_MESSAGES', + 'messages.po', + ) + with open(path, encoding='utf-8') as handle: + catalogue_ids = {message.id for message in read_po(handle) if message.id} + missing = source_ids - catalogue_ids + assert not missing, ( + f'{len(missing)} source string(s) absent from {locale}: ' + f'{sorted(missing, key=str)[:5]}' + ) + class TestSelectorUnit: def test_select_locale_returns_a_supported_code(self, app): diff --git a/tests/test_schema_report.py b/tests/test_schema_report.py index 8b2fd51..ff3b27e 100644 --- a/tests/test_schema_report.py +++ b/tests/test_schema_report.py @@ -257,3 +257,36 @@ class TestSeedAccountCheck: output = capsys.readouterr().out assert 'password has been changed' in output assert 'PASSWORD IS STILL' not in output + + +class TestDiscordIdentityCheck: + def test_it_finds_identity_collisions_before_the_unique_migration(self, live_db, app, capsys): + engine, _tamper = live_db + path = str(engine.url).replace('sqlite:///', '') + connection = sqlite3.connect(path) + for username in ('alice', 'bob'): + connection.execute( + 'INSERT INTO users ' + '(username, password_hash, role, full_name, email, ' + 'is_active_account, discord_user_id) ' + "VALUES (?, 'hash', 'player', ?, ?, 1, '222222222222222222')", + (username, username.title(), f'{username}@example.test'), + ) + connection.commit() + connection.close() + + with app.app_context(): + result = main(['--url', str(engine.url), '--check-discord-identities']) + + output = capsys.readouterr().out + assert result == 1 + assert '222222222222222222: 2 accounts (alice, bob)' in output + + def test_a_clean_identity_set_does_not_change_the_exit_code(self, live_db, app, capsys): + engine, _tamper = live_db + + with app.app_context(): + result = main(['--url', str(engine.url), '--check-discord-identities']) + + assert result == 0 + assert 'No Discord identity is shared' in capsys.readouterr().out