From db908e3109e86e17ebb02e11cbf1061df461f2f0 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 8 Aug 2026 11:30:37 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Rafforza=20i=20requisiti=20password=20con?= =?UTF-8?q?=20regole=20di=20complessit=C3=A0=20di=20mercato.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../controllers/admin/passwords_controller.rb | 13 +++-- .../controllers/api/v1/accounts_controller.rb | 3 + .../public/password_resets_controller.rb | 8 +-- backend/app/models/admin_account.rb | 2 + .../models/concerns/password_complexity.rb | 55 +++++++++++++++++++ backend/app/models/user.rb | 2 + backend/app/services/users/change_password.rb | 14 +++-- .../app/views/public/accounts/show.html.erb | 1 + .../public/password_resets/edit.html.erb | 1 + .../views/public/registrations/new.html.erb | 1 + backend/config/locales/admin.de.yml | 4 +- backend/config/locales/admin.en.yml | 4 +- backend/config/locales/admin.es.yml | 4 +- backend/config/locales/admin.fr.yml | 4 +- backend/config/locales/admin.it.yml | 4 +- backend/config/locales/app.de.yml | 22 ++++++++ backend/config/locales/app.en.yml | 22 ++++++++ backend/config/locales/app.es.yml | 22 ++++++++ backend/config/locales/app.fr.yml | 22 ++++++++ backend/config/locales/app.it.yml | 27 +++++++++ backend/config/locales/ui.de.yml | 4 +- backend/config/locales/ui.en.yml | 4 +- backend/config/locales/ui.es.yml | 4 +- backend/config/locales/ui.fr.yml | 4 +- backend/config/locales/ui.it.yml | 4 +- .../spec/models/password_complexity_spec.rb | 36 ++++++++++++ backend/spec/requests/account_spec.rb | 27 ++++++--- backend/spec/requests/auth_spec.rb | 4 +- native/android/.gitignore | 1 + .../app/src/main/res/values-de/strings.xml | 2 +- .../app/src/main/res/values-en/strings.xml | 2 +- .../app/src/main/res/values-es/strings.xml | 2 +- .../app/src/main/res/values-fr/strings.xml | 2 +- .../app/src/main/res/values/strings.xml | 2 +- native/ios/MatchLiveTv/Core/AppLanguage.swift | 10 ++-- 35 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 backend/app/models/concerns/password_complexity.rb create mode 100644 backend/spec/models/password_complexity_spec.rb diff --git a/backend/app/controllers/admin/passwords_controller.rb b/backend/app/controllers/admin/passwords_controller.rb index 60b34d3..31a6d8d 100644 --- a/backend/app/controllers/admin/passwords_controller.rb +++ b/backend/app/controllers/admin/passwords_controller.rb @@ -9,13 +9,18 @@ module Admin return render :edit, status: :unprocessable_entity end - if params[:password].blank? || params[:password].length < 8 - flash.now[:alert] = t("admin.flash.password_too_short") + if params[:password] != params[:password_confirmation] + flash.now[:alert] = t("admin.flash.password_mismatch") return render :edit, status: :unprocessable_entity end - if params[:password] != params[:password_confirmation] - flash.now[:alert] = t("admin.flash.password_mismatch") + if (code = PasswordComplexity.violation(params[:password])) + key = case code + when :blank, :too_short then :password_too_short + when :too_long then :password_too_long + else :password_too_weak + end + flash.now[:alert] = t("admin.flash.#{key}") return render :edit, status: :unprocessable_entity end diff --git a/backend/app/controllers/api/v1/accounts_controller.rb b/backend/app/controllers/api/v1/accounts_controller.rb index fc03a72..0f6b438 100644 --- a/backend/app/controllers/api/v1/accounts_controller.rb +++ b/backend/app/controllers/api/v1/accounts_controller.rb @@ -48,6 +48,9 @@ module Api case code when :current_incorrect then "Current password is incorrect" when :too_short then "Password must be at least 8 characters" + when :too_long then "Password cannot exceed 72 characters" + when :too_weak + "Password must include at least 3 of: lowercase, uppercase, number, symbol" when :mismatch then "Passwords do not match" else "Unable to update password" end diff --git a/backend/app/controllers/public/password_resets_controller.rb b/backend/app/controllers/public/password_resets_controller.rb index 5e3b9f1..7bb941b 100644 --- a/backend/app/controllers/public/password_resets_controller.rb +++ b/backend/app/controllers/public/password_resets_controller.rb @@ -28,14 +28,14 @@ module Public return end - if params[:password].blank? || params[:password].length < 8 - flash.now[:alert] = t("flash.password_resets.password_min_length") + if params[:password] != params[:password_confirmation] + flash.now[:alert] = t("flash.password_resets.password_mismatch") @token = params[:token] return render :edit, status: :unprocessable_entity end - if params[:password] != params[:password_confirmation] - flash.now[:alert] = t("flash.password_resets.password_mismatch") + if (code = PasswordComplexity.violation(params[:password])) + flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}") @token = params[:token] return render :edit, status: :unprocessable_entity end diff --git a/backend/app/models/admin_account.rb b/backend/app/models/admin_account.rb index 37fdc0a..92b722c 100644 --- a/backend/app/models/admin_account.rb +++ b/backend/app/models/admin_account.rb @@ -1,4 +1,6 @@ class AdminAccount < ApplicationRecord + include PasswordComplexity + has_secure_password validates :username, presence: true, uniqueness: true diff --git a/backend/app/models/concerns/password_complexity.rb b/backend/app/models/concerns/password_complexity.rb new file mode 100644 index 0000000..f07a261 --- /dev/null +++ b/backend/app/models/concerns/password_complexity.rb @@ -0,0 +1,55 @@ +# Criteri "di mercato" (stile Cognito/Auth0 bilanciato): +# - minimo 8 caratteri (max 72 per bcrypt) +# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli +module PasswordComplexity + extend ActiveSupport::Concern + + MIN_LENGTH = 8 + MAX_LENGTH = 72 + REQUIRED_CLASSES = 3 + + CLASS_CHECKS = { + lowercase: /[a-z]/, + uppercase: /[A-Z]/, + digit: /\d/, + symbol: /[^A-Za-z0-9]/ + }.freeze + + class << self + def violation(password) + value = password.to_s + return :blank if value.blank? + return :too_short if value.length < MIN_LENGTH + return :too_long if value.bytesize > MAX_LENGTH + return :too_weak unless strong_enough?(value) + + nil + end + + def strong_enough?(password) + matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) } + matched >= REQUIRED_CLASSES + end + + def requirement_summary + I18n.t("password_policy.hint") + end + end + + included do + validate :password_meets_complexity_policy, if: -> { password.present? } + end + + private + + def password_meets_complexity_policy + case PasswordComplexity.violation(password) + when :too_short + errors.add(:password, :too_short, count: MIN_LENGTH) + when :too_long + errors.add(:password, :too_long, count: MAX_LENGTH) + when :too_weak + errors.add(:password, :complexity) + end + end +end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 9e26314..bb3adc6 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + include PasswordComplexity + ROLES = %w[admin coach parent volunteer].freeze has_secure_password diff --git a/backend/app/services/users/change_password.rb b/backend/app/services/users/change_password.rb index 5351bc0..ce1f22b 100644 --- a/backend/app/services/users/change_password.rb +++ b/backend/app/services/users/change_password.rb @@ -23,15 +23,19 @@ module Users return Result.new(ok?: false, error: :current_incorrect) end - if @password.blank? || @password.length < 8 - return Result.new(ok?: false, error: :too_short) - end - if @password != @password_confirmation return Result.new(ok?: false, error: :mismatch) end - @user.update!(password: @password) + if (code = PasswordComplexity.violation(@password)) + return Result.new(ok?: false, error: code == :blank ? :too_short : code) + end + + unless @user.update(password: @password) + complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity } + return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short) + end + @user.clear_password_reset! Result.new(ok?: true) end diff --git a/backend/app/views/public/accounts/show.html.erb b/backend/app/views/public/accounts/show.html.erb index e715c4c..cad7696 100644 --- a/backend/app/views/public/accounts/show.html.erb +++ b/backend/app/views/public/accounts/show.html.erb @@ -30,6 +30,7 @@

<%= t("auth.account.password_heading") %>

+

<%= t("password_policy.hint") %>

<%= form_with url: public_account_password_path, method: :patch, local: true do %> <%= render "shared/input_toggle", name: :current_password, diff --git a/backend/app/views/public/password_resets/edit.html.erb b/backend/app/views/public/password_resets/edit.html.erb index 72db3ee..7fc0b25 100644 --- a/backend/app/views/public/password_resets/edit.html.erb +++ b/backend/app/views/public/password_resets/edit.html.erb @@ -4,6 +4,7 @@

<%= t("auth.password_reset.title") %>

+

<%= t("password_policy.hint") %>

<%= form_with url: public_password_reset_path, method: :patch, local: true do %> <%= hidden_field_tag :token, @token %> <%= render "shared/input_toggle", diff --git a/backend/app/views/public/registrations/new.html.erb b/backend/app/views/public/registrations/new.html.erb index e20d0e2..209dd42 100644 --- a/backend/app/views/public/registrations/new.html.erb +++ b/backend/app/views/public/registrations/new.html.erb @@ -25,6 +25,7 @@ required: true, minlength: 8, autocomplete: "new-password" %> +

<%= t("password_policy.hint") %>

<%= render "shared/input_toggle", name: "user[password_confirmation]", id: "user_password_confirmation", diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index e3e45a3..1b8b270 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -18,6 +18,8 @@ de: logout_success: Abgemeldet password_current_incorrect: Das aktuelle Passwort ist falsch password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein + password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein + password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert ops_acknowledged: Vorfall übernommen @@ -53,7 +55,7 @@ de: edit: title: Passwort ändern current_password_label: Aktuelles Passwort - new_password_label: "Neues Passwort (mind. 8 Zeichen)" + new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)" confirm_password_label: Neues Passwort bestätigen submit: Passwort speichern cancel: Abbrechen diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 8ef9e23..8f2d686 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -18,6 +18,8 @@ en: logout_success: Signed out password_current_incorrect: Current password is incorrect password_too_short: The new password must be at least 8 characters long + password_too_long: New password cannot exceed 72 characters + password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols" password_mismatch: Passwords do not match password_updated: Password updated ops_acknowledged: Incident acknowledged @@ -53,7 +55,7 @@ en: edit: title: Change password current_password_label: Current password - new_password_label: "New password (min. 8 characters)" + new_password_label: "New password (min. 8, at least 3 character types)" confirm_password_label: Confirm new password submit: Save password cancel: Cancel diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 728bc45..5342ad9 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -18,6 +18,8 @@ es: logout_success: Sesión cerrada password_current_incorrect: La contraseña actual no es correcta password_too_short: La nueva contraseña debe tener al menos 8 caracteres + password_too_long: La nueva contraseña no puede superar los 72 caracteres + password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada ops_acknowledged: Incidencia asumida @@ -53,7 +55,7 @@ es: edit: title: Cambiar contraseña current_password_label: Contraseña actual - new_password_label: "Nueva contraseña (mín. 8 caracteres)" + new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)" confirm_password_label: Confirma la nueva contraseña submit: Guardar contraseña cancel: Cancelar diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 68725e6..d64e47c 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -18,6 +18,8 @@ fr: logout_success: Déconnecté password_current_incorrect: Le mot de passe actuel est incorrect password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères + password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères + password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour ops_acknowledged: Incident pris en charge @@ -53,7 +55,7 @@ fr: edit: title: Changer le mot de passe current_password_label: Mot de passe actuel - new_password_label: "Nouveau mot de passe (min. 8 caractères)" + new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)" confirm_password_label: Confirmer le nouveau mot de passe submit: Enregistrer le mot de passe cancel: Annuler diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index 466c08c..f70e2f5 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -18,6 +18,8 @@ it: logout_success: Disconnesso password_current_incorrect: Password attuale non corretta password_too_short: La nuova password deve avere almeno 8 caratteri + password_too_long: La nuova password non può superare i 72 caratteri + password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_mismatch: Le password non coincidono password_updated: Password aggiornata ops_acknowledged: Incidente preso in carico @@ -53,7 +55,7 @@ it: edit: title: Cambia password current_password_label: Password attuale - new_password_label: "Nuova password (min. 8 caratteri)" + new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)" confirm_password_label: Conferma nuova password submit: Salva password cancel: Annulla diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 68b778e..1e71300 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -1,4 +1,21 @@ de: + password_policy: + hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen." + activerecord: + errors: + models: + user: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten" + admin_account: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten" club: back_to_club: "← Verein" sport_label: Hauptsportart @@ -604,6 +621,9 @@ de: email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts. invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an. password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein + password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein + password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein + password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden. accounts: @@ -611,6 +631,8 @@ de: profile_updated: Profil aktualisiert. password_current_incorrect: Das aktuelle Passwort ist falsch password_too_short: Das Passwort muss mindestens 8 Zeichen haben + password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein + password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert. replay: diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index b5c9355..7c2f9db 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -1,4 +1,21 @@ en: + password_policy: + hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols." + activerecord: + errors: + models: + user: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols" + admin_account: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols" club: back_to_club: "← Club" sport_label: Main sport @@ -604,6 +621,9 @@ en: email_sent: If the email is registered, you'll receive a password reset link shortly. invalid_or_expired_link: Invalid or expired link. Request a new password reset. password_min_length: Password must be at least 8 characters + password_too_short: Password must be at least 8 characters + password_too_long: Password cannot exceed 72 characters + password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols" password_mismatch: Passwords don't match password_updated: Password updated. You can now log in. accounts: @@ -611,6 +631,8 @@ en: profile_updated: Profile updated. password_current_incorrect: Current password is incorrect password_too_short: Password must be at least 8 characters + password_too_long: Password cannot exceed 72 characters + password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols" password_mismatch: Passwords do not match password_updated: Password updated. replay: diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index d1d984b..476e945 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -1,4 +1,21 @@ es: + password_policy: + hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos." + activerecord: + errors: + models: + user: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" + admin_account: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" club: back_to_club: "← Club" sport_label: Deporte principal @@ -604,6 +621,9 @@ es: email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña. invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña. password_min_length: La contraseña debe tener al menos 8 caracteres + password_too_short: La contraseña debe tener al menos 8 caracteres + password_too_long: La contraseña no puede superar los 72 caracteres + password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada. Ya puedes iniciar sesión. accounts: @@ -611,6 +631,8 @@ es: profile_updated: Perfil actualizado. password_current_incorrect: La contraseña actual no es correcta password_too_short: La contraseña debe tener al menos 8 caracteres + password_too_long: La contraseña no puede superar los 72 caracteres + password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada. replay: diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index bd8a4f0..d34f3a1 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -1,4 +1,21 @@ fr: + password_policy: + hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles." + activerecord: + errors: + models: + user: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" + admin_account: + attributes: + password: + too_short: "is too short (minimum is %{count} characters)" + too_long: "is too long (maximum is %{count} characters)" + complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" club: back_to_club: "← Club" sport_label: Sport principal @@ -604,6 +621,9 @@ fr: email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe. invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe. password_min_length: Le mot de passe doit comporter au moins 8 caractères + password_too_short: Le mot de passe doit comporter au moins 8 caractères + password_too_long: Le mot de passe ne peut pas dépasser 72 caractères + password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter. accounts: @@ -611,6 +631,8 @@ fr: profile_updated: Profil mis à jour. password_current_incorrect: Le mot de passe actuel est incorrect password_too_short: Le mot de passe doit contenir au moins 8 caractères + password_too_long: Le mot de passe ne peut pas dépasser 72 caractères + password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour. replay: diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index eee0e1d..613df5e 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -1,4 +1,21 @@ it: + password_policy: + hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli." + activerecord: + errors: + models: + user: + attributes: + password: + too_short: "è troppo corta (minimo %{count} caratteri)" + too_long: "è troppo lunga (massimo %{count} caratteri)" + complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli" + admin_account: + attributes: + password: + too_short: "è troppo corta (minimo %{count} caratteri)" + too_long: "è troppo lunga (massimo %{count} caratteri)" + complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli" club: back_to_club: "← Società" sport_label: Sport principale @@ -604,6 +621,12 @@ it: email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password. invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password. password_min_length: La password deve avere almeno 8 caratteri + password_too_short: La password deve avere almeno 8 caratteri + password_too_long: La password non può superare i 72 caratteri + password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" + password_too_short: La password deve avere almeno 8 caratteri + password_too_long: La password non può superare i 72 caratteri + password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_mismatch: Le password non coincidono password_updated: Password aggiornata. Ora puoi accedere. accounts: @@ -611,6 +634,10 @@ it: profile_updated: Profilo aggiornato. password_current_incorrect: La password attuale non è corretta password_too_short: La password deve avere almeno 8 caratteri + password_too_long: La password non può superare i 72 caratteri + password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" + password_too_long: La password non può superare i 72 caratteri + password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_mismatch: Le password non coincidono password_updated: Password aggiornata. replay: diff --git a/backend/config/locales/ui.de.yml b/backend/config/locales/ui.de.yml index 9c3231a..88cf26c 100644 --- a/backend/config/locales/ui.de.yml +++ b/backend/config/locales/ui.de.yml @@ -103,7 +103,7 @@ de: password_reset: meta_title: "Neues Passwort — Match Live TV" title: Neues Passwort wählen - new_password_label: "Neues Passwort (min. 8 Zeichen)" + new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)" submit: Passwort speichern back_to_login: Zurück zur Anmeldung invitation: @@ -128,7 +128,7 @@ de: name_label: Name role_label: "Rolle: %{role}" current_password_label: Aktuelles Passwort - new_password_label: "Neues Passwort (mind. 8 Zeichen)" + new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)" save_profile: Profil speichern save_password: Passwort aktualisieren common: diff --git a/backend/config/locales/ui.en.yml b/backend/config/locales/ui.en.yml index ea1e0ef..114682c 100644 --- a/backend/config/locales/ui.en.yml +++ b/backend/config/locales/ui.en.yml @@ -103,7 +103,7 @@ en: password_reset: meta_title: "New password — Match Live TV" title: Choose a new password - new_password_label: "New password (min. 8 characters)" + new_password_label: "New password (min. 8, at least 3 character types)" submit: Save password back_to_login: Back to login invitation: @@ -128,7 +128,7 @@ en: name_label: Name role_label: "Role: %{role}" current_password_label: Current password - new_password_label: "New password (min. 8 characters)" + new_password_label: "New password (min. 8, at least 3 character types)" save_profile: Save profile save_password: Update password common: diff --git a/backend/config/locales/ui.es.yml b/backend/config/locales/ui.es.yml index d230d9a..a4b3e74 100644 --- a/backend/config/locales/ui.es.yml +++ b/backend/config/locales/ui.es.yml @@ -103,7 +103,7 @@ es: password_reset: meta_title: "Nueva contraseña — Match Live TV" title: Elige una nueva contraseña - new_password_label: "Nueva contraseña (mín. 8 caracteres)" + new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)" submit: Guardar contraseña back_to_login: Volver al inicio de sesión invitation: @@ -128,7 +128,7 @@ es: name_label: Nombre role_label: "Rol: %{role}" current_password_label: Contraseña actual - new_password_label: "Nueva contraseña (mín. 8 caracteres)" + new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)" save_profile: Guardar perfil save_password: Actualizar contraseña common: diff --git a/backend/config/locales/ui.fr.yml b/backend/config/locales/ui.fr.yml index 4711125..7072601 100644 --- a/backend/config/locales/ui.fr.yml +++ b/backend/config/locales/ui.fr.yml @@ -103,7 +103,7 @@ fr: password_reset: meta_title: "Nouveau mot de passe — Match Live TV" title: Choisissez un nouveau mot de passe - new_password_label: "Nouveau mot de passe (8 caractères min.)" + new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)" submit: Enregistrer le mot de passe back_to_login: Retour à la connexion invitation: @@ -128,7 +128,7 @@ fr: name_label: Nom role_label: "Rôle : %{role}" current_password_label: Mot de passe actuel - new_password_label: "Nouveau mot de passe (min. 8 caractères)" + new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)" save_profile: Enregistrer le profil save_password: Mettre à jour le mot de passe common: diff --git a/backend/config/locales/ui.it.yml b/backend/config/locales/ui.it.yml index 91e5a68..daf409c 100644 --- a/backend/config/locales/ui.it.yml +++ b/backend/config/locales/ui.it.yml @@ -103,7 +103,7 @@ it: password_reset: meta_title: "Nuova password — Match Live TV" title: Scegli una nuova password - new_password_label: "Nuova password (min. 8 caratteri)" + new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)" submit: Salva password back_to_login: Torna al login invitation: @@ -128,7 +128,7 @@ it: name_label: Nome role_label: "Ruolo: %{role}" current_password_label: Password attuale - new_password_label: "Nuova password (min. 8 caratteri)" + new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)" save_profile: Salva profilo save_password: Aggiorna password common: diff --git a/backend/spec/models/password_complexity_spec.rb b/backend/spec/models/password_complexity_spec.rb new file mode 100644 index 0000000..12b6634 --- /dev/null +++ b/backend/spec/models/password_complexity_spec.rb @@ -0,0 +1,36 @@ +require "rails_helper" + +RSpec.describe PasswordComplexity do + describe ".violation" do + it "accepts a password with 3 character classes" do + expect(described_class.violation("NewPass123")).to be_nil + end + + it "accepts symbols instead of one letter class" do + expect(described_class.violation("newpass1!")).to be_nil + end + + it "rejects passwords that are too short" do + expect(described_class.violation("Ab1!")).to eq(:too_short) + end + + it "rejects passwords with fewer than 3 classes" do + expect(described_class.violation("password123")).to eq(:too_weak) + expect(described_class.violation("PASSWORD123")).to eq(:too_weak) + expect(described_class.violation("Password")).to eq(:too_weak) + end + end + + describe "User validation" do + it "blocks weak passwords on create" do + user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach") + expect(user).not_to be_valid + expect(user.errors[:password]).to be_present + end + + it "allows strong passwords on create" do + user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach") + expect(user).to be_valid + end + end +end diff --git a/backend/spec/requests/account_spec.rb b/backend/spec/requests/account_spec.rb index a008fdb..eb9d98c 100644 --- a/backend/spec/requests/account_spec.rb +++ b/backend/spec/requests/account_spec.rb @@ -1,9 +1,9 @@ require "rails_helper" RSpec.describe "Account API", type: :request do - let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "password123", role: "coach") } + let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "Password123", role: "coach") } let(:auth_headers) do - post "/api/v1/auth/login", params: { email: user.email, password: "password123" } + post "/api/v1/auth/login", params: { email: user.email, password: "Password123" } token = JSON.parse(response.body).fetch("access_token") { "Authorization" => "Bearer #{token}" } end @@ -40,25 +40,38 @@ RSpec.describe "Account API", type: :request do it "changes the password with the current password" do patch "/api/v1/account/password", params: { - current_password: "password123", - password: "newpass123", - password_confirmation: "newpass123" + current_password: "Password123", + password: "NewPass123", + password_confirmation: "NewPass123" }, headers: auth_headers expect(response).to have_http_status(:ok) - expect(user.reload.authenticate("newpass123")).to be_truthy + expect(user.reload.authenticate("NewPass123")).to be_truthy end it "rejects an incorrect current password" do patch "/api/v1/account/password", params: { current_password: "wrong", + password: "NewPass123", + password_confirmation: "NewPass123" + }, + headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + expect(user.reload.authenticate("Password123")).to be_truthy + end + + it "rejects a password that fails complexity rules" do + patch "/api/v1/account/password", + params: { + current_password: "Password123", password: "newpass123", password_confirmation: "newpass123" }, headers: auth_headers expect(response).to have_http_status(:unprocessable_entity) - expect(user.reload.authenticate("password123")).to be_truthy + expect(JSON.parse(response.body)["error"]).to match(/3 of/i) + expect(user.reload.authenticate("Password123")).to be_truthy end end diff --git a/backend/spec/requests/auth_spec.rb b/backend/spec/requests/auth_spec.rb index ea29b2e..26c43bf 100644 --- a/backend/spec/requests/auth_spec.rb +++ b/backend/spec/requests/auth_spec.rb @@ -1,10 +1,10 @@ require "rails_helper" RSpec.describe "Auth API", type: :request do - let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") } + let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "Password123", role: "coach") } it "logs in with valid credentials" do - post "/api/v1/auth/login", params: { email: user.email, password: "password123" } + post "/api/v1/auth/login", params: { email: user.email, password: "Password123" } expect(response).to have_http_status(:ok) expect(JSON.parse(response.body)).to have_key("access_token") end diff --git a/native/android/.gitignore b/native/android/.gitignore index f2cfc63..4f6e5dd 100644 --- a/native/android/.gitignore +++ b/native/android/.gitignore @@ -5,6 +5,7 @@ *.iml /captures/ /app/build/ +/dist/ .DS_Store keystore.properties keystore/ diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml index 444ac2c..34dba73 100644 --- a/native/android/app/src/main/res/values-de/strings.xml +++ b/native/android/app/src/main/res/values-de/strings.xml @@ -252,7 +252,7 @@ Dies kann nicht rückgängig gemacht werden. Name Rolle: %1$s Aktuelles Passwort - Neues Passwort (mind. 8 Zeichen) + Neues Passwort (mind. 8, mindestens 3 Zeichenarten) Passwort bestätigen Profil speichern Passwort aktualisieren diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml index d6148fb..7612762 100644 --- a/native/android/app/src/main/res/values-en/strings.xml +++ b/native/android/app/src/main/res/values-en/strings.xml @@ -252,7 +252,7 @@ This cannot be undone. Name Role: %1$s Current password - New password (min. 8 characters) + New password (min. 8, at least 3 character types) Confirm password Save profile Update password diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml index 38b39e8..70f3564 100644 --- a/native/android/app/src/main/res/values-es/strings.xml +++ b/native/android/app/src/main/res/values-es/strings.xml @@ -252,7 +252,7 @@ Esta acción no se puede deshacer. Nombre Rol: %1$s Contraseña actual - Nueva contraseña (mín. 8 caracteres) + Nueva contraseña (mín. 8, al menos 3 tipos de caracteres) Confirmar contraseña Guardar perfil Actualizar contraseña diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml index bdd5c90..6a4a6fd 100644 --- a/native/android/app/src/main/res/values-fr/strings.xml +++ b/native/android/app/src/main/res/values-fr/strings.xml @@ -252,7 +252,7 @@ Cette action est irréversible. Nom Rôle : %1$s Mot de passe actuel - Nouveau mot de passe (min. 8 caractères) + Nouveau mot de passe (min. 8, au moins 3 types de caractères) Confirmer le mot de passe Enregistrer le profil Mettre à jour le mot de passe diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml index 508eb18..15842f1 100644 --- a/native/android/app/src/main/res/values/strings.xml +++ b/native/android/app/src/main/res/values/strings.xml @@ -252,7 +252,7 @@ L\'operazione non si può annullare. Nome Ruolo: %1$s Password attuale - Nuova password (min. 8 caratteri) + Nuova password (min. 8, almeno 3 tipi di caratteri) Conferma password Salva profilo Aggiorna password diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift index f2782f3..3cf4b5b 100644 --- a/native/ios/MatchLiveTv/Core/AppLanguage.swift +++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift @@ -81,7 +81,7 @@ enum L10n { "account.confirm.password": "Conferma password", "account.current.password": "Password attuale", "account.name.label": "Nome", - "account.new.password": "Nuova password (min. 8 caratteri)", + "account.new.password": "Nuova password (min. 8, almeno 3 tipi di caratteri)", "account.password.heading": "Cambia password", "account.password.saved": "Password aggiornata", "account.profile.heading": "Profilo", @@ -372,7 +372,7 @@ enum L10n { "account.confirm.password": "Confirm password", "account.current.password": "Current password", "account.name.label": "Name", - "account.new.password": "New password (min. 8 characters)", + "account.new.password": "New password (min. 8, at least 3 character types)", "account.password.heading": "Change password", "account.password.saved": "Password updated", "account.profile.heading": "Profile", @@ -663,7 +663,7 @@ enum L10n { "account.confirm.password": "Confirmer le mot de passe", "account.current.password": "Mot de passe actuel", "account.name.label": "Nom", - "account.new.password": "Nouveau mot de passe (min. 8 caractères)", + "account.new.password": "Nouveau mot de passe (min. 8, au moins 3 types de caractères)", "account.password.heading": "Changer le mot de passe", "account.password.saved": "Mot de passe mis à jour", "account.profile.heading": "Profil", @@ -954,7 +954,7 @@ enum L10n { "account.confirm.password": "Passwort bestätigen", "account.current.password": "Aktuelles Passwort", "account.name.label": "Name", - "account.new.password": "Neues Passwort (mind. 8 Zeichen)", + "account.new.password": "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)", "account.password.heading": "Passwort ändern", "account.password.saved": "Passwort aktualisiert", "account.profile.heading": "Profil", @@ -1245,7 +1245,7 @@ enum L10n { "account.confirm.password": "Confirmar contraseña", "account.current.password": "Contraseña actual", "account.name.label": "Nombre", - "account.new.password": "Nueva contraseña (mín. 8 caracteres)", + "account.new.password": "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)", "account.password.heading": "Cambiar contraseña", "account.password.saved": "Contraseña actualizada", "account.profile.heading": "Perfil", From 53449c5d3c1d6254c0ee85af84992271e37c9cd1 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 8 Aug 2026 11:30:51 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Allinea=20le=20password=20di=20seed=20alla?= =?UTF-8?q?=20nuova=20policy=20di=20complessit=C3=A0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/db/seeds.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index ac4d1a3..9ffd5bc 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -1,18 +1,18 @@ load Rails.root.join("db/seeds/plans.rb") AdminAccount.find_or_create_by!(username: "admin") do |a| - a.password = "admin" + a.password = "AdminPass123" end coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u| u.name = "Coach Demo" - u.password = "password123" + u.password = "Password123" u.role = "coach" end admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u| u.name = "Admin" - u.password = "password123" + u.password = "Password123" u.role = "admin" end @@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m m.phase = "Semifinale" end -puts "Seed OK: coach@matchlivetv.test / password123" +puts "Seed OK: coach@matchlivetv.test / Password123" puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}" From dd9901519a3d8a65cf596f0af18c15e55f0f7208 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 8 Aug 2026 11:31:47 +0200 Subject: [PATCH 3/4] Rifiuta il riuso della password attuale in cambio e reset. Co-authored-by: Cursor --- .../app/controllers/admin/passwords_controller.rb | 5 +++++ .../app/controllers/api/v1/accounts_controller.rb | 1 + .../public/password_resets_controller.rb | 6 ++++++ backend/app/models/concerns/password_complexity.rb | 7 +++++++ backend/app/services/users/change_password.rb | 4 ++++ backend/config/locales/admin.de.yml | 1 + backend/config/locales/admin.en.yml | 1 + backend/config/locales/admin.es.yml | 1 + backend/config/locales/admin.fr.yml | 1 + backend/config/locales/admin.it.yml | 1 + backend/config/locales/app.de.yml | 2 ++ backend/config/locales/app.en.yml | 2 ++ backend/config/locales/app.es.yml | 2 ++ backend/config/locales/app.fr.yml | 2 ++ backend/config/locales/app.it.yml | 2 ++ backend/spec/models/password_complexity_spec.rb | 9 +++++++++ backend/spec/requests/account_spec.rb | 13 +++++++++++++ 17 files changed, 60 insertions(+) diff --git a/backend/app/controllers/admin/passwords_controller.rb b/backend/app/controllers/admin/passwords_controller.rb index 31a6d8d..52742fd 100644 --- a/backend/app/controllers/admin/passwords_controller.rb +++ b/backend/app/controllers/admin/passwords_controller.rb @@ -24,6 +24,11 @@ module Admin return render :edit, status: :unprocessable_entity end + if PasswordComplexity.same_as_current?(current_admin_account, params[:password]) + flash.now[:alert] = t("admin.flash.password_same_as_current") + return render :edit, status: :unprocessable_entity + end + if current_admin_account.update(password: params[:password]) redirect_to admin_root_path, notice: t("admin.flash.password_updated") else diff --git a/backend/app/controllers/api/v1/accounts_controller.rb b/backend/app/controllers/api/v1/accounts_controller.rb index 0f6b438..d903ed3 100644 --- a/backend/app/controllers/api/v1/accounts_controller.rb +++ b/backend/app/controllers/api/v1/accounts_controller.rb @@ -51,6 +51,7 @@ module Api when :too_long then "Password cannot exceed 72 characters" when :too_weak "Password must include at least 3 of: lowercase, uppercase, number, symbol" + when :same_as_current then "New password must be different from the current password" when :mismatch then "Passwords do not match" else "Unable to update password" end diff --git a/backend/app/controllers/public/password_resets_controller.rb b/backend/app/controllers/public/password_resets_controller.rb index 7bb941b..28d62e7 100644 --- a/backend/app/controllers/public/password_resets_controller.rb +++ b/backend/app/controllers/public/password_resets_controller.rb @@ -40,6 +40,12 @@ module Public return render :edit, status: :unprocessable_entity end + if PasswordComplexity.same_as_current?(@user, params[:password]) + flash.now[:alert] = t("flash.password_resets.password_same_as_current") + @token = params[:token] + return render :edit, status: :unprocessable_entity + end + @user.update!(password: params[:password]) @user.clear_password_reset! redirect_to public_login_path, notice: t("flash.password_resets.password_updated") diff --git a/backend/app/models/concerns/password_complexity.rb b/backend/app/models/concerns/password_complexity.rb index f07a261..5c43f32 100644 --- a/backend/app/models/concerns/password_complexity.rb +++ b/backend/app/models/concerns/password_complexity.rb @@ -31,6 +31,13 @@ module PasswordComplexity matched >= REQUIRED_CLASSES end + # Confronta con il digest già salvato (prima di assegnare la nuova password). + def same_as_current?(record, password) + return false if password.blank? || !record.respond_to?(:authenticate) + + record.authenticate(password).present? + end + def requirement_summary I18n.t("password_policy.hint") end diff --git a/backend/app/services/users/change_password.rb b/backend/app/services/users/change_password.rb index ce1f22b..39b57d7 100644 --- a/backend/app/services/users/change_password.rb +++ b/backend/app/services/users/change_password.rb @@ -31,6 +31,10 @@ module Users return Result.new(ok?: false, error: code == :blank ? :too_short : code) end + if PasswordComplexity.same_as_current?(@user, @password) + return Result.new(ok?: false, error: :same_as_current) + end + unless @user.update(password: @password) complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity } return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short) diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index 1b8b270..d65159c 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -20,6 +20,7 @@ de: password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten + password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert ops_acknowledged: Vorfall übernommen diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 8f2d686..1f8d295 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -20,6 +20,7 @@ en: password_too_short: The new password must be at least 8 characters long password_too_long: New password cannot exceed 72 characters password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols" + password_same_as_current: New password must be different from the current password password_mismatch: Passwords do not match password_updated: Password updated ops_acknowledged: Incident acknowledged diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 5342ad9..e1f1c9c 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -20,6 +20,7 @@ es: password_too_short: La nueva contraseña debe tener al menos 8 caracteres password_too_long: La nueva contraseña no puede superar los 72 caracteres password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" + password_same_as_current: La nueva contraseña debe ser distinta de la actual password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada ops_acknowledged: Incidencia asumida diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index d64e47c..a9a0915 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -20,6 +20,7 @@ fr: password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" + password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour ops_acknowledged: Incident pris en charge diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index f70e2f5..ffdf74e 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -20,6 +20,7 @@ it: password_too_short: La nuova password deve avere almeno 8 caratteri password_too_long: La nuova password non può superare i 72 caratteri password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" + password_same_as_current: La nuova password deve essere diversa da quella attuale password_mismatch: Le password non coincidono password_updated: Password aggiornata ops_acknowledged: Incidente preso in carico diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 1e71300..4e6f62c 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -624,6 +624,7 @@ de: password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten + password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden. accounts: @@ -633,6 +634,7 @@ de: password_too_short: Das Passwort muss mindestens 8 Zeichen haben password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten + password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert. replay: diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index 7c2f9db..e91282e 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -624,6 +624,7 @@ en: password_too_short: Password must be at least 8 characters password_too_long: Password cannot exceed 72 characters password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols" + password_same_as_current: New password must be different from the current password password_mismatch: Passwords don't match password_updated: Password updated. You can now log in. accounts: @@ -633,6 +634,7 @@ en: password_too_short: Password must be at least 8 characters password_too_long: Password cannot exceed 72 characters password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols" + password_same_as_current: New password must be different from the current password password_mismatch: Passwords do not match password_updated: Password updated. replay: diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index 476e945..c1ff4ae 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -624,6 +624,7 @@ es: password_too_short: La contraseña debe tener al menos 8 caracteres password_too_long: La contraseña no puede superar los 72 caracteres password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" + password_same_as_current: La nueva contraseña debe ser distinta de la actual password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada. Ya puedes iniciar sesión. accounts: @@ -633,6 +634,7 @@ es: password_too_short: La contraseña debe tener al menos 8 caracteres password_too_long: La contraseña no puede superar los 72 caracteres password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" + password_same_as_current: La nueva contraseña debe ser distinta de la actual password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada. replay: diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index d34f3a1..d7fd649 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -624,6 +624,7 @@ fr: password_too_short: Le mot de passe doit comporter au moins 8 caractères password_too_long: Le mot de passe ne peut pas dépasser 72 caractères password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" + password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter. accounts: @@ -633,6 +634,7 @@ fr: password_too_short: Le mot de passe doit contenir au moins 8 caractères password_too_long: Le mot de passe ne peut pas dépasser 72 caractères password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" + password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour. replay: diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index 613df5e..341e9a1 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -624,6 +624,7 @@ it: password_too_short: La password deve avere almeno 8 caratteri password_too_long: La password non può superare i 72 caratteri password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" + password_same_as_current: La nuova password deve essere diversa da quella attuale password_too_short: La password deve avere almeno 8 caratteri password_too_long: La password non può superare i 72 caratteri password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" @@ -636,6 +637,7 @@ it: password_too_short: La password deve avere almeno 8 caratteri password_too_long: La password non può superare i 72 caratteri password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" + password_same_as_current: La nuova password deve essere diversa da quella attuale password_too_long: La password non può superare i 72 caratteri password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_mismatch: Le password non coincidono diff --git a/backend/spec/models/password_complexity_spec.rb b/backend/spec/models/password_complexity_spec.rb index 12b6634..4e928f9 100644 --- a/backend/spec/models/password_complexity_spec.rb +++ b/backend/spec/models/password_complexity_spec.rb @@ -21,6 +21,15 @@ RSpec.describe PasswordComplexity do end end + describe ".same_as_current?" do + let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") } + + it "detects when the new password matches the current one" do + expect(described_class.same_as_current?(user, "Password123")).to eq(true) + expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false) + end + end + describe "User validation" do it "blocks weak passwords on create" do user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach") diff --git a/backend/spec/requests/account_spec.rb b/backend/spec/requests/account_spec.rb index eb9d98c..3a87e8c 100644 --- a/backend/spec/requests/account_spec.rb +++ b/backend/spec/requests/account_spec.rb @@ -73,6 +73,19 @@ RSpec.describe "Account API", type: :request do expect(JSON.parse(response.body)["error"]).to match(/3 of/i) expect(user.reload.authenticate("Password123")).to be_truthy end + + it "rejects reusing the current password" do + patch "/api/v1/account/password", + params: { + current_password: "Password123", + password: "Password123", + password_confirmation: "Password123" + }, + headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)["error"]).to match(/different/i) + expect(user.reload.authenticate("Password123")).to be_truthy + end end describe "POST /api/v1/auth/password/forgot" do From 1d1cbf9f3f28bb84456295e22555d9f668993bb3 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 8 Aug 2026 14:14:42 +0200 Subject: [PATCH 4/4] Localizza errori API, sistema layout mobile e documenta allineamento iOS. Gli header Accept-Language dalle app e i fix di padding/menu sul web chiudono il giro password-policy; il doc guida il lavoro su Mac. Co-authored-by: Cursor --- .../controllers/api/v1/accounts_controller.rb | 19 ++- .../app/controllers/api/v1/auth_controller.rb | 8 +- .../app/controllers/application_controller.rb | 10 +- .../controllers/public/accounts_controller.rb | 4 +- .../public/password_resets_controller.rb | 4 +- backend/app/views/layouts/marketing.html.erb | 2 +- .../app/views/layouts/marketing_live.html.erb | 2 +- .../views/public/invitations/show.html.erb | 46 +++--- .../app/views/public/pages/cookies.html.erb | 12 +- .../app/views/public/pages/pricing.html.erb | 30 ++-- .../app/views/public/pages/privacy.html.erb | 6 +- .../views/shared/_billing_documents.html.erb | 2 + .../views/shared/_language_switcher.html.erb | 8 ++ .../app/views/shared/_marketing_nav.html.erb | 22 ++- backend/config/locales/app.de.yml | 16 ++- backend/config/locales/app.en.yml | 3 + backend/config/locales/app.es.yml | 16 ++- backend/config/locales/app.fr.yml | 16 ++- backend/config/locales/app.it.yml | 3 + backend/config/routes.rb | 1 + backend/public/marketing.css | 123 +++++++++++++--- backend/spec/requests/account_spec.rb | 26 +++- docs/IOS_PASSWORD_POLICY_ALIGN.md | 136 ++++++++++++++++++ .../match_live_tv/data/AppContainer.kt | 11 ++ .../MatchLiveTv/Data/API/MatchLiveAPI.swift | 2 + 25 files changed, 430 insertions(+), 98 deletions(-) create mode 100644 docs/IOS_PASSWORD_POLICY_ALIGN.md diff --git a/backend/app/controllers/api/v1/accounts_controller.rb b/backend/app/controllers/api/v1/accounts_controller.rb index d903ed3..1ef42cd 100644 --- a/backend/app/controllers/api/v1/accounts_controller.rb +++ b/backend/app/controllers/api/v1/accounts_controller.rb @@ -8,7 +8,7 @@ module Api def update name = params[:name].to_s.strip if name.blank? - return render json: { error: "Name is required" }, status: :unprocessable_entity + return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity end if current_user.update(name: name) @@ -30,7 +30,7 @@ module Api return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity end - render json: { message: "Password updated" } + render json: { message: I18n.t("flash.accounts.password_updated") } end private @@ -46,14 +46,13 @@ module Api def password_error_message(code) case code - when :current_incorrect then "Current password is incorrect" - when :too_short then "Password must be at least 8 characters" - when :too_long then "Password cannot exceed 72 characters" - when :too_weak - "Password must include at least 3 of: lowercase, uppercase, number, symbol" - when :same_as_current then "New password must be different from the current password" - when :mismatch then "Passwords do not match" - else "Unable to update password" + when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect") + when :too_short then I18n.t("flash.accounts.password_too_short") + when :too_long then I18n.t("flash.accounts.password_too_long") + when :too_weak then I18n.t("flash.accounts.password_too_weak") + when :same_as_current then I18n.t("flash.accounts.password_same_as_current") + when :mismatch then I18n.t("flash.accounts.password_mismatch") + else I18n.t("flash.accounts.password_update_failed") end end end diff --git a/backend/app/controllers/api/v1/auth_controller.rb b/backend/app/controllers/api/v1/auth_controller.rb index f0b7439..7f9b886 100644 --- a/backend/app/controllers/api/v1/auth_controller.rb +++ b/backend/app/controllers/api/v1/auth_controller.rb @@ -22,18 +22,18 @@ module Api if user&.authenticate(params[:password]) render json: token_response(user), status: :ok else - render json: { error: "Invalid credentials" }, status: :unauthorized + render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized end end def logout - render json: { message: "Logged out" } + render json: { message: I18n.t("flash.sessions.logged_out") } end def refresh payload = JsonWebToken.decode(params[:refresh_token] || bearer_token) user = User.find_by(id: payload&.dig(:user_id)) - return render json: { error: "Invalid token" }, status: :unauthorized unless user + return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user render json: token_response(user) end @@ -45,7 +45,7 @@ module Api def forgot_password Users::RequestPasswordReset.call(email: params[:email]) render json: { - message: "If the email is registered, you will receive a password reset link shortly." + message: I18n.t("flash.password_resets.email_sent") } end diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 029725c..94f9d34 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,17 +1,25 @@ class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods + before_action :set_api_locale before_action :authenticate_request! attr_reader :current_user private + def set_api_locale + explicit = LocaleResolver.normalize(request.headers["X-Locale"]) + I18n.locale = explicit || + LocaleResolver.from_accept_language(request.headers["Accept-Language"]) || + I18n.default_locale + end + def authenticate_request! token = bearer_token payload = JsonWebToken.decode(token) @current_user = User.find_by(id: payload[:user_id]) if payload - render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user + render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user end def bearer_token diff --git a/backend/app/controllers/public/accounts_controller.rb b/backend/app/controllers/public/accounts_controller.rb index 5677824..f327719 100644 --- a/backend/app/controllers/public/accounts_controller.rb +++ b/backend/app/controllers/public/accounts_controller.rb @@ -29,8 +29,8 @@ module Public ) unless result.ok? - flash.now[:alert] = t("flash.accounts.password_#{result.error}") - return render :show, status: :unprocessable_entity + redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}") + return end redirect_to public_account_path, notice: t("flash.accounts.password_updated") diff --git a/backend/app/controllers/public/password_resets_controller.rb b/backend/app/controllers/public/password_resets_controller.rb index 28d62e7..82948dd 100644 --- a/backend/app/controllers/public/password_resets_controller.rb +++ b/backend/app/controllers/public/password_resets_controller.rb @@ -13,7 +13,7 @@ module Public def edit @user = User.find_by_password_reset_token(params[:token]) if @user.nil? || @user.password_reset_expired? - redirect_to new_public_password_reset_path, + redirect_to public_password_forgot_path, alert: t("flash.password_resets.invalid_or_expired_link") return end @@ -23,7 +23,7 @@ module Public def update @user = User.find_by_password_reset_token(params[:token]) if @user.nil? || @user.password_reset_expired? - redirect_to new_public_password_reset_path, + redirect_to public_password_forgot_path, alert: t("flash.password_resets.invalid_or_expired_link") return end diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index f1dbedc..0561bfa 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index af3b302..048d895 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= yield :head %> diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index bb232e3..b07d377 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -2,25 +2,27 @@ <% content_for :meta_description, t("auth.invitation.meta_description") %> <% content_for :robots, "noindex, nofollow" %> -
-

<%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %>

-

<%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %>

-

- <%= raw t("auth.invitation.instructions_html", email: @invitation.email) %> -

- <% if logged_in? %> - <%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> - <% else %> -

<%= raw t( - "auth.invitation.login_or_signup_html", - login_link: link_to(t("auth.invitation.login_link"), public_login_path), - signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path), - email: @invitation.email - ) %>

- <%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> - <% end %> -

- <%= t("auth.invitation.mobile_app_label") %> - <%= t("auth.invitation.open_in_app") %> -

-
+
+
+

<%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %>

+

<%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %>

+

+ <%= raw t("auth.invitation.instructions_html", email: @invitation.email) %> +

+ <% if logged_in? %> + <%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> + <% else %> +

<%= raw t( + "auth.invitation.login_or_signup_html", + login_link: link_to(t("auth.invitation.login_link"), public_login_path), + signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path), + email: @invitation.email + ) %>

+ <%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> + <% end %> +

+ <%= t("auth.invitation.mobile_app_label") %> + <%= t("auth.invitation.open_in_app") %> +

+
+
diff --git a/backend/app/views/public/pages/cookies.html.erb b/backend/app/views/public/pages/cookies.html.erb index 4154906..b3d0634 100644 --- a/backend/app/views/public/pages/cookies.html.erb +++ b/backend/app/views/public/pages/cookies.html.erb @@ -46,7 +46,8 @@

<%= t("legal.cookies.s4_1_title") %>

<%= t("legal.cookies.s4_1_intro") %>

- +
+
@@ -64,7 +65,8 @@ - + +

<%= t("legal.cookies.s4_2_title") %>

@@ -75,7 +77,8 @@ <% else %>

<%= t("legal.cookies.s4_2_inactive") %>

<% end %> - +
+
@@ -99,7 +102,8 @@ - + +

<%= raw t( "legal.cookies.s4_2_p2_html", diff --git a/backend/app/views/public/pages/pricing.html.erb b/backend/app/views/public/pages/pricing.html.erb index 8d4a774..329362e 100644 --- a/backend/app/views/public/pages/pricing.html.erb +++ b/backend/app/views/public/pages/pricing.html.erb @@ -22,20 +22,22 @@ <%= render "shared/plan_cards" %> - - - - - - - - - - - - - -
FreePremium LightPremium Full
<%= t("pages.pricing.table_staff") %>15<%= t("pages.pricing.table_unlimited") %>
<%= t("pages.pricing.table_matches") %>1310
<%= t("pages.pricing.table_live_mltv") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_youtube") %><%= t("pages.pricing.table_no") %>Match Live TV<%= t("pages.pricing.table_youtube_club") %>
<%= t("pages.pricing.table_replay") %><%= t("pages.pricing.table_no") %><%= t("pages.plans.replay_days", count: 30) %><%= t("pages.plans.replay_days", count: 90) %>
<%= t("pages.pricing.table_download") %><%= t("pages.pricing.table_no") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_price") %><%= t("pages.pricing.table_price_free") %><%= raw t("pages.pricing.table_price_light_html") %><%= raw t("pages.pricing.table_price_full_html") %>
+

+ + + + + + + + + + + + + +
FreePremium LightPremium Full
<%= t("pages.pricing.table_staff") %>15<%= t("pages.pricing.table_unlimited") %>
<%= t("pages.pricing.table_matches") %>1310
<%= t("pages.pricing.table_live_mltv") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_youtube") %><%= t("pages.pricing.table_no") %>Match Live TV<%= t("pages.pricing.table_youtube_club") %>
<%= t("pages.pricing.table_replay") %><%= t("pages.pricing.table_no") %><%= t("pages.plans.replay_days", count: 30) %><%= t("pages.plans.replay_days", count: 90) %>
<%= t("pages.pricing.table_download") %><%= t("pages.pricing.table_no") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_price") %><%= t("pages.pricing.table_price_free") %><%= raw t("pages.pricing.table_price_light_html") %><%= raw t("pages.pricing.table_price_full_html") %>
+

<%= t("pages.pricing.different_title") %>

diff --git a/backend/app/views/public/pages/privacy.html.erb b/backend/app/views/public/pages/privacy.html.erb index 20fbde4..9884044 100644 --- a/backend/app/views/public/pages/privacy.html.erb +++ b/backend/app/views/public/pages/privacy.html.erb @@ -77,7 +77,8 @@

<%= t("legal.privacy.s5_title") %>

- +
+
@@ -107,7 +108,8 @@ - + +
diff --git a/backend/app/views/shared/_billing_documents.html.erb b/backend/app/views/shared/_billing_documents.html.erb index fd7d792..2081dfb 100644 --- a/backend/app/views/shared/_billing_documents.html.erb +++ b/backend/app/views/shared/_billing_documents.html.erb @@ -20,6 +20,7 @@

<%= t("billing.documents.table_heading") %>

<% if payments.any? %> +
@@ -60,6 +61,7 @@ <% end %>
+
<% else %>

<%= t("billing.documents.no_payments") %>

<% end %> diff --git a/backend/app/views/shared/_language_switcher.html.erb b/backend/app/views/shared/_language_switcher.html.erb index ba8b5df..3594d05 100644 --- a/backend/app/views/shared/_language_switcher.html.erb +++ b/backend/app/views/shared/_language_switcher.html.erb @@ -58,10 +58,18 @@ } toggle.addEventListener("click", function (e) { + e.preventDefault(); e.stopPropagation(); setOpen(menu.hidden); }); + // Keep parent mobile nav open while interacting with the switcher UI. + root.addEventListener("click", function (e) { + if (e.target.closest(".lang-switcher__toggle")) { + e.stopPropagation(); + } + }); + document.addEventListener("click", function (e) { if (!root.contains(e.target)) setOpen(false); }); diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb index 1a34c10..3ad5ecb 100644 --- a/backend/app/views/shared/_marketing_nav.html.erb +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -19,6 +19,15 @@ @@ -78,8 +84,16 @@ if (backdrop) backdrop.addEventListener("click", closeMenu); + function shouldCloseNavOnControl(el) { + // Language toggle must keep the mobile menu open; only a locale choice may close it. + if (!el.closest("[data-lang-switcher]")) return true; + return el.classList.contains("lang-switcher__option"); + } + nav.querySelectorAll("a, button").forEach(function (el) { - el.addEventListener("click", closeMenu); + el.addEventListener("click", function () { + if (shouldCloseNavOnControl(el)) closeMenu(); + }); }); window.addEventListener("resize", function () { diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 4e6f62c..63e5db2 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -2,19 +2,24 @@ de: password_policy: hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen." activerecord: + attributes: + user: + password: Passwort + admin_account: + password: Passwort errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "ist zu kurz (mindestens %{count} Zeichen)" + too_long: "ist zu lang (höchstens %{count} Zeichen)" complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "ist zu kurz (mindestens %{count} Zeichen)" + too_long: "ist zu lang (höchstens %{count} Zeichen)" complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten" club: back_to_club: "← Verein" @@ -617,6 +622,8 @@ de: welcome_back: Willkommen zurück! invalid_credentials: E-Mail oder Passwort ungültig logged_out: Abgemeldet + unauthorized: Nicht autorisiert + invalid_token: Ungültiges Token password_resets: email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts. invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an. @@ -637,6 +644,7 @@ de: password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden password_mismatch: Die Passwörter stimmen nicht überein password_updated: Passwort aktualisiert. + password_update_failed: Passwort konnte nicht aktualisiert werden replay: download_unavailable: Download nicht verfügbar not_available: Replay nicht verfügbar diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index e91282e..1150d77 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -617,6 +617,8 @@ en: welcome_back: Welcome back! invalid_credentials: Invalid email or password logged_out: Logged out + unauthorized: Unauthorized + invalid_token: Invalid token password_resets: email_sent: If the email is registered, you'll receive a password reset link shortly. invalid_or_expired_link: Invalid or expired link. Request a new password reset. @@ -637,6 +639,7 @@ en: password_same_as_current: New password must be different from the current password password_mismatch: Passwords do not match password_updated: Password updated. + password_update_failed: Unable to update password replay: download_unavailable: Download not available not_available: Replay not available diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index c1ff4ae..08c1647 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -2,19 +2,24 @@ es: password_policy: hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos." activerecord: + attributes: + user: + password: Contraseña + admin_account: + password: Contraseña errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "es demasiado corta (mínimo %{count} caracteres)" + too_long: "es demasiado larga (máximo %{count} caracteres)" complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "es demasiado corta (mínimo %{count} caracteres)" + too_long: "es demasiado larga (máximo %{count} caracteres)" complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos" club: back_to_club: "← Club" @@ -617,6 +622,8 @@ es: welcome_back: "¡Bienvenido de nuevo!" invalid_credentials: Correo o contraseña no válidos logged_out: Sesión cerrada + unauthorized: No autorizado + invalid_token: Token no válido password_resets: email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña. invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña. @@ -637,6 +644,7 @@ es: password_same_as_current: La nueva contraseña debe ser distinta de la actual password_mismatch: Las contraseñas no coinciden password_updated: Contraseña actualizada. + password_update_failed: No se pudo actualizar la contraseña replay: download_unavailable: Descarga no disponible not_available: Repetición no disponible diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index d7fd649..60f7f8c 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -2,19 +2,24 @@ fr: password_policy: hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles." activerecord: + attributes: + user: + password: Mot de passe + admin_account: + password: Mot de passe errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "est trop court (minimum %{count} caractères)" + too_long: "est trop long (maximum %{count} caractères)" complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + too_short: "est trop court (minimum %{count} caractères)" + too_long: "est trop long (maximum %{count} caractères)" complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" club: back_to_club: "← Club" @@ -617,6 +622,8 @@ fr: welcome_back: Bon retour ! invalid_credentials: E-mail ou mot de passe invalide logged_out: Déconnecté + unauthorized: Non autorisé + invalid_token: Jeton invalide password_resets: email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe. invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe. @@ -637,6 +644,7 @@ fr: password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel" password_mismatch: Les mots de passe ne correspondent pas password_updated: Mot de passe mis à jour. + password_update_failed: Impossible de mettre à jour le mot de passe replay: download_unavailable: Téléchargement non disponible not_available: Replay non disponible diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index 341e9a1..7cbb6bf 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -617,6 +617,8 @@ it: welcome_back: Bentornato! invalid_credentials: Email o password non validi logged_out: Disconnesso + unauthorized: Non autorizzato + invalid_token: Token non valido password_resets: email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password. invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password. @@ -642,6 +644,7 @@ it: password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_mismatch: Le password non coincidono password_updated: Password aggiornata. + password_update_failed: Impossibile aggiornare la password replay: download_unavailable: Download non disponibile not_available: Replay non disponibile diff --git a/backend/config/routes.rb b/backend/config/routes.rb index dd5d8cf..5b37e3a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -169,6 +169,7 @@ Rails.application.routes.draw do patch "password/reset", to: "password_resets#update" get "account", to: "accounts#show", as: :account patch "account", to: "accounts#update" + get "account/password", to: redirect("/account"), as: nil patch "account/password", to: "accounts#update_password", as: :account_password get "clubs/new", to: "clubs#new", as: :new_club post "clubs", to: "clubs#create" diff --git a/backend/public/marketing.css b/backend/public/marketing.css index bd440db..9a80bcb 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; } body.nav-menu-open .site-chrome { z-index: 1300; } + /* Keep the close control above the full-screen sheet; hide duplicate mast brand. */ + body.nav-menu-open .site-masthead { + z-index: 1320; + background: transparent; + border-bottom-color: transparent; + backdrop-filter: none; + } + body.nav-menu-open .mast-brand { + visibility: hidden; + pointer-events: none; + } .nav-backdrop { display: block; position: fixed; @@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; } .nav-panel { flex: 1; flex-direction: column; + flex-wrap: nowrap; align-items: stretch; gap: 0; width: 100%; max-width: none; + min-height: 0; margin: 0; - padding: 72px 24px 32px; - padding-top: calc(72px + env(safe-area-inset-top, 0px)); + padding: 16px 24px 32px; + padding-top: calc(16px + env(safe-area-inset-top, 0px)); padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); + overflow-x: hidden; overflow-y: auto; } + .nav-mobile-head { + order: -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 44px; + margin: 0 0 8px; + padding: 0 52px 16px 0; /* room for the close (X) control on the right */ + border-bottom: 1px solid #252530; + flex-shrink: 0; + } + .nav-mobile-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + text-decoration: none; + line-height: 1; + } + .nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; } + .nav-mobile-brand .brand { font-size: 1.05rem; } + .nav-mobile-brand-logo { + display: block; + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; + } .nav-panel > a, .nav-panel .nav-link-item { display: block; @@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; } } .nav-actions { flex-direction: column; + flex-wrap: nowrap; align-items: stretch; - gap: 14px; - margin-top: 24px; - padding: 24px 0 0; - border-top: 1px solid #252530; + gap: 0; + margin-top: 8px; + padding: 0; + border-top: none; + width: 100%; } .nav-actions .nav-link-item { display: block; @@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; } border-radius: 10px; } .nav-lang { - margin-left: 0; - margin-top: 4px; + margin: 0; justify-content: flex-end; - width: 100%; + width: auto; + flex-shrink: 0; + } + .nav-lang .lang-switcher__menu { + /* Keep the list inside the open mobile sheet */ + z-index: 1320; } } @@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; } flex-wrap: nowrap; gap: 8px 20px; } + .nav-mobile-head { + display: contents; + } + .nav-mobile-brand { + display: none; + } + .nav-lang { + order: 2; + margin-left: 2px; + } .nav-actions { + order: 1; flex-wrap: nowrap; gap: 8px 12px; margin-left: auto; } - .nav-lang { - margin-left: 2px; - } .nav-backdrop { display: none !important; } } .btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; } @@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; } .hero-split .hero-cta { flex-direction: column; } .hero-split .hero-cta .btn { width: 100%; text-align: center; } } -.section { padding: 40px 0; } +.section { + padding-top: 40px; + padding-bottom: 40px; +} +/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */ +.section.wrap { + padding-left: 20px; + padding-right: 20px; +} +.table-scroll { + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-top: 20px; +} .section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } .step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; } @@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; } .site-footer__legal { flex: 1 1 100%; margin-top: 4px; } .site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } .site-footer__legal p:last-child { margin-bottom: 0; } -.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } +.compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th { color: #aaa; font-weight: 600; } .billing-documents h2 { margin-top: 0; } @@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; } .card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; } -.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; } +.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; } .seo-prose h2:first-child { margin-top: 0; } .seo-prose p { margin: 0 0 14px; } .seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; } .seo-prose a { color: #e53935; } +@media (max-width: 899px) { + .seo-prose h2 { font-size: 1.15rem; line-height: 1.35; } +} .seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; } .faq-list { max-width: 720px; margin: 0 auto; } .faq-item { @@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; } .legal-doc a { color: #e53935; } .legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; } .legal-back { margin-top: 32px; } -.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; } +.legal-doc .table-scroll { margin: 12px 0 16px; } +.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; } .legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; } .legal-table th { background: #14141c; color: #ccc; } .legal-accept { @@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; } .auth-forgot a { color: #e53935; } table.data { width: 100%; border-collapse: collapse; } table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; } +/* Wide roster/match tables: scroll inside the card instead of expanding the page. */ +@media (max-width: 899px) { + .card:has(table.data), + .team-streaming-staff:has(table.data), + .billing-documents:has(table.data) { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; + } + table.data { + min-width: 520px; + } +} input, select { width: 100%; padding: 12px 14px; diff --git a/backend/spec/requests/account_spec.rb b/backend/spec/requests/account_spec.rb index 3a87e8c..2dc7742 100644 --- a/backend/spec/requests/account_spec.rb +++ b/backend/spec/requests/account_spec.rb @@ -68,7 +68,7 @@ RSpec.describe "Account API", type: :request do password: "newpass123", password_confirmation: "newpass123" }, - headers: auth_headers + headers: auth_headers.merge("Accept-Language" => "en") expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)["error"]).to match(/3 of/i) expect(user.reload.authenticate("Password123")).to be_truthy @@ -81,14 +81,26 @@ RSpec.describe "Account API", type: :request do password: "Password123", password_confirmation: "Password123" }, - headers: auth_headers + headers: auth_headers.merge("Accept-Language" => "en") expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)["error"]).to match(/different/i) expect(user.reload.authenticate("Password123")).to be_truthy end + + it "localizes password errors from Accept-Language" do + patch "/api/v1/account/password", + params: { + current_password: "Password123", + password: "Password123", + password_confirmation: "Password123" + }, + headers: auth_headers.merge("Accept-Language" => "it") + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale") + end end - describe "POST /api/v1/auth/password/forgot" do + describe "POST /api/v1/auth/password/forgot" do it "always returns ok and sends mail when the user exists" do expect { post "/api/v1/auth/password/forgot", params: { email: user.email } @@ -103,5 +115,13 @@ RSpec.describe "Account API", type: :request do }.not_to change { ActionMailer::Base.deliveries.size } expect(response).to have_http_status(:ok) end + + it "localizes the response message from Accept-Language" do + post "/api/v1/auth/password/forgot", + params: { email: user.email }, + headers: { "Accept-Language" => "fr" } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)["message"]).to include("réinitialiser") + end end end diff --git a/docs/IOS_PASSWORD_POLICY_ALIGN.md b/docs/IOS_PASSWORD_POLICY_ALIGN.md new file mode 100644 index 0000000..d583452 --- /dev/null +++ b/docs/IOS_PASSWORD_POLICY_ALIGN.md @@ -0,0 +1,136 @@ +# iOS — allineamento password policy + errori API localizzati + +Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`). + +**Aggiornato:** 2026-08-08 +**Android di riferimento (testato su emulatore → API locale):** `2.0.10-native` (`versionCode` **31**) +**iOS oggi:** marketing `2.0.5` / build `26` +**API produzione:** `https://www.matchlivetv.it` + +--- + +## Contesto (già in produzione backend dopo questo rilascio) + +### Policy password (server) + +Definita in `backend/app/models/concerns/password_complexity.rb`: + +- minimo **8** caratteri, massimo **72** byte (bcrypt) +- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli +- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale + +Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano: + +| Endpoint | Note | +|----------|------| +| `PATCH /api/v1/account/password` | App native + stessi codici errore | +| `PATCH /account/password` (web) | Flash I18n | +| reset password pubblico | Stesse regole | +| registrazione (`auth/register`) | Validazione modello | + +### Errori API localizzati + +`ApplicationController#set_api_locale` legge, in ordine: + +1. header `X-Locale` +2. `Accept-Language` +3. `I18n.default_locale` (fallback; **retrocompatibile** se l’app vecchia non manda nulla) + +Risposta invariata: `{ "error": "" }` (o `{ "message": "..." }` su alcuni successi). + +Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`): + +- `flash.accounts.password_too_short` +- `flash.accounts.password_too_long` +- `flash.accounts.password_too_weak` +- `flash.accounts.password_same_as_current` +- `flash.accounts.password_current_incorrect` +- `flash.accounts.password_mismatch` +- `flash.accounts.password_updated` / `name_required` +- analoghi in `flash.password_resets.*` e `flash.sessions.*` + +--- + +## Stato iOS vs Android (dopo il merge di questo branch) + +| Area | Android 2.0.10 | iOS (repo dopo merge) | Da fare su Mac | +|------|----------------|------------------------|----------------| +| Header `Accept-Language` su tutte le request API | `AppContainer` OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **Verificare** in debug che parta su login/account/password; se manca su altri helper HTTP, allineare | +| Hint UI nuova password | `account_new_password` in `values*` (5 lingue) | `account.new.password` in `AppLanguage.swift` (già allineato al testo policy) | OK — rivedi solo se cambi copy | +| Schermata Account | Mostra `error` dal body HTTP | `AccountScreen` + `APIError` estrae `error`/`message` | **Test manuale** (vedi sotto) | +| Parsing errori API | Regex su `"error"` | `APIError.friendlyHttpMessage` | OK | +| Client-side pre-check complessità | No (si affida al server) | No | Opzionale: stessa regola di `PasswordComplexity` prima della PATCH | +| Versione App Store | `2.0.10-native` / 31 | `2.0.5` / 26 | **Bump** marketing + build prima del submit TestFlight/App Store | +| Signup in-app | Non è il flusso principale | Nessuna UI register dedicata | N/A (signup resta web) | + +### File già toccati / da tenere + +``` +native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # Accept-Language +native/ios/MatchLiveTv/Core/AppLanguage.swift # hint account.new.password (5 lingue) +native/ios/MatchLiveTv/UI/Account/AccountScreen.swift +native/ios/MatchLiveTv/Core/UserFacingError.swift +native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # enum APIError +``` + +Riferimento Android: + +``` +native/android/.../data/AppContainer.kt +native/android/.../ui/account/AccountScreen.kt +native/android/app/src/main/res/values*/strings.xml # account_new_password +``` + +--- + +## Checklist operativa su Mac + +1. **Pull** `main` (dopo merge/push da Linux). +2. Apri `native/ios/MatchLiveTv.xcodeproj`. +3. Build debug con `API_BASE_URL=https://www.matchlivetv.it` (o locale se hai tunnel). +4. **Test Account** (credenziali demo se disponibili, altrimenti account reale di test): + - password debole (`password`) → messaggio *too_weak* nella lingua UI + - password = corrente → *same_as_current* + - conferma diversa → *mismatch* + - password valida nuova → successo; login successivo ok +5. Cambia lingua app (globo) e ripeti un errore: il testo API deve seguire `Accept-Language` / lingua scelta. +6. **Bump versione** allineata ad Android se rilasci store: es. marketing `2.0.10`, build `≥ 31`. +7. (Opzionale) Unit test Swift che replica `PasswordComplexity.strong_enough?` se aggiungi validazione client. + +### Comandi utili + +```bash +# Clone/pull +git checkout main && git pull + +# Release iOS (script repo) +./scripts/build_ios_release.sh +# oppure API staging: +# API_BASE_URL=https://www.matchlivetv.it ./scripts/build_ios_release.sh +``` + +### Cosa non serve rifare su iOS + +- Fix CSS/menu mobile web, tabelle scroll, logo nel drawer: **solo sito**. +- Redirect `GET /account/password` → `/account`: **solo web**. +- Seed password / concern Rails: già lato server. + +--- + +## Retrocompatibilità (per release iOS) + +- App iOS **vecchie** (senza `Accept-Language`): continuano a funzionare; messaggi API nella locale default del server. +- Login con password già esistenti: invariato. +- Solo **impostazione/cambio** password debole viene rifiutato (stesso JSON `error`). + +--- + +## Criterio “fatto” + +- [ ] `Accept-Language` presente su login, me, account, change password, forgot (Charles/Proxyman o log) +- [ ] Errori password in it/en/fr/de/es coerenti col backend +- [ ] Hint campo nuova password parla di “min. 8 / 3 tipi” +- [ ] Versione bumpata se lo store release è in scope +- [ ] Nessuna regressione login / lista partite / broadcast + +Quando completo, aggiorna questo file con data + versione iOS rilasciata. diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt index e1450b1..f15b262 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data import android.content.Context import com.matchlivetv.match_live_tv.core.AppConfig +import com.matchlivetv.match_live_tv.core.AppLocale import com.matchlivetv.match_live_tv.core.TokenStore import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.cable.SessionCableService @@ -37,10 +38,20 @@ class AppContainer(context: Context) { accessToken?.let { header("Authorization", "Bearer $it") } header("Accept", "application/json") header("Content-Type", "application/json") + header("Accept-Language", apiLanguageTag()) }.build() chain.proceed(request) } + private fun apiLanguageTag(): String { + val tag = AppLocale.currentTag(appContext) + return if (tag.isBlank()) { + java.util.Locale.getDefault().language.ifBlank { "it" } + } else { + tag + } + } + private val okHttp = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift index d76b3ba..fa2f3d7 100644 --- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -240,6 +240,7 @@ final class MatchLiveAPI: @unchecked Sendable { request.httpMethod = "PATCH" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language") if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary) return try await execute(request) @@ -253,6 +254,7 @@ final class MatchLiveAPI: @unchecked Sendable { var request = URLRequest(url: baseURL.appendingPathComponent(path)) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language") if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try ApiInstant.encoder.encode(body)