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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
@@ -2,25 +2,27 @@
|
||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||
</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= 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
|
||||
) %></p>
|
||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
<section class="auth-page">
|
||||
<div class="card">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||
</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= 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
|
||||
) %></p>
|
||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
|
||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
</thead>
|
||||
@@ -64,7 +65,8 @@
|
||||
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||
<p>
|
||||
@@ -75,7 +77,8 @@
|
||||
<% else %>
|
||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||
<% end %>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
</thead>
|
||||
@@ -99,7 +102,8 @@
|
||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.cookies.s4_2_p2_html",
|
||||
|
||||
@@ -22,20 +22,22 @@
|
||||
|
||||
<%= render "shared/plan_cards" %>
|
||||
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||
</thead>
|
||||
@@ -107,7 +108,8 @@
|
||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||
<% if payments.any? %>
|
||||
<div class="table-scroll">
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -60,6 +61,7 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<div class="nav-mobile-head">
|
||||
<%= link_to root_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
@@ -40,9 +49,6 @@
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+107
-16
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<messaggio già tradotto>" }` (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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user