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 23c3123..8fc3943 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index e4301be..1d92bad 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/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: 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 b9f3180..bc11174 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ 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