Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 | ||
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -9,13 +9,23 @@ module Admin
|
|||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
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
|
||||||
|
|
||||||
|
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
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
def show
|
||||||
|
render json: user_json(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
render json: user_json(current_user)
|
||||||
|
else
|
||||||
|
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_json(user)
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_error_message(code)
|
||||||
|
case code
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class AuthController < ApplicationController
|
class AuthController < ApplicationController
|
||||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||||
|
|
||||||
def register
|
def register
|
||||||
user = User.new(
|
user = User.new(
|
||||||
@@ -22,18 +22,18 @@ module Api
|
|||||||
if user&.authenticate(params[:password])
|
if user&.authenticate(params[:password])
|
||||||
render json: token_response(user), status: :ok
|
render json: token_response(user), status: :ok
|
||||||
else
|
else
|
||||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logout
|
def logout
|
||||||
render json: { message: "Logged out" }
|
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh
|
def refresh
|
||||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||||
user = User.find_by(id: payload&.dig(:user_id))
|
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)
|
render json: token_response(user)
|
||||||
end
|
end
|
||||||
@@ -42,6 +42,13 @@ module Api
|
|||||||
render json: user_json(current_user)
|
render json: user_json(current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def forgot_password
|
||||||
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
|
render json: {
|
||||||
|
message: I18n.t("flash.password_resets.email_sent")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def token_response(user)
|
def token_response(user)
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||||
|
|
||||||
|
before_action :set_api_locale
|
||||||
before_action :authenticate_request!
|
before_action :authenticate_request!
|
||||||
|
|
||||||
attr_reader :current_user
|
attr_reader :current_user
|
||||||
|
|
||||||
private
|
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!
|
def authenticate_request!
|
||||||
token = bearer_token
|
token = bearer_token
|
||||||
payload = JsonWebToken.decode(token)
|
payload = JsonWebToken.decode(token)
|
||||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
@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
|
end
|
||||||
|
|
||||||
def bearer_token
|
def bearer_token
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Public
|
||||||
|
class AccountsController < WebBaseController
|
||||||
|
before_action :require_login!
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
flash.now[:alert] = t("flash.accounts.name_required")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
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")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,11 +4,7 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
if user
|
|
||||||
token = user.generate_password_reset!
|
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to public_login_path,
|
redirect_to public_login_path,
|
||||||
notice: t("flash.password_resets.email_sent")
|
notice: t("flash.password_resets.email_sent")
|
||||||
@@ -17,7 +13,7 @@ module Public
|
|||||||
def edit
|
def edit
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
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")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -27,19 +23,25 @@ module Public
|
|||||||
def update
|
def update
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
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")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||||
@token = params[:token]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
|
||||||
|
@token = params[:token]
|
||||||
|
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]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AdminAccount < ApplicationRecord
|
class AdminAccount < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|
||||||
validates :username, presence: true, uniqueness: true
|
validates :username, presence: true, uniqueness: true
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
ROLES = %w[admin coach parent volunteer].freeze
|
ROLES = %w[admin coach parent volunteer].freeze
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ module Ops
|
|||||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||||
)
|
)
|
||||||
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
||||||
ok_finding("garage_storage:ok", "Garage storage OK")
|
ok_finding("garage_storage:head", "Garage storage OK")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -116,31 +116,30 @@ module Streams
|
|||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
|
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||||
|
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||||
def youtube_ffmpeg_args(intake_source, output)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
mode, url = intake_source
|
mode, url = intake_source
|
||||||
|
common_head = [
|
||||||
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
|
"-fflags", "+genpts+discardcorrupt"
|
||||||
|
]
|
||||||
|
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||||
|
|
||||||
if mode == :rtmp
|
if mode == :rtmp
|
||||||
return [
|
return common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-i", url,
|
"-i", url
|
||||||
"-c:v", "copy",
|
] + copy_tail
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
|
||||||
"-bsf:a", "aac_adtstoasc",
|
|
||||||
"-f", "flv", output
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
[
|
common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
"-c:a", "copy",
|
||||||
"-bsf:a", "aac_adtstoasc",
|
"-bsf:a", "aac_adtstoasc",
|
||||||
"-f", "flv", output
|
"-f", "flv", output
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Users
|
||||||
|
class ChangePassword
|
||||||
|
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||||
|
new(
|
||||||
|
user: user,
|
||||||
|
current_password: current_password,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password_confirmation
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||||
|
@user = user
|
||||||
|
@current_password = current_password.to_s
|
||||||
|
@password = password.to_s
|
||||||
|
@password_confirmation = password_confirmation.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
unless @user.authenticate(@current_password)
|
||||||
|
return Result.new(ok?: false, error: :current_incorrect)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @password != @password_confirmation
|
||||||
|
return Result.new(ok?: false, error: :mismatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
if (code = PasswordComplexity.violation(@password))
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
@user.clear_password_reset!
|
||||||
|
Result.new(ok?: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module Users
|
||||||
|
class RequestPasswordReset
|
||||||
|
def self.call(email:)
|
||||||
|
new(email: email).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(email:)
|
||||||
|
@email = email.to_s.downcase.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
user = User.find_by(email: @email)
|
||||||
|
return if user.nil?
|
||||||
|
|
||||||
|
token = user.generate_password_reset!
|
||||||
|
UserMailer.password_reset(user, token).deliver_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<%= yield :head %>
|
<%= 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="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>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<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" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= 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="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">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<% elsif current_user.manageable_teams.any? %>
|
<% elsif current_user.manageable_teams.any? %>
|
||||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
· <%= link_to t("nav.account"), public_account_path %>
|
||||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
· <%= link_to t("nav.login"), public_login_path %>
|
· <%= link_to t("nav.login"), public_login_path %>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<% content_for :title, t("auth.account.meta_title") %>
|
||||||
|
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<section class="auth-page">
|
||||||
|
<h1><%= t("auth.account.title") %></h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||||
|
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||||
|
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||||
|
class="input" autocomplete="username"
|
||||||
|
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||||
|
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||||
|
</p>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :name,
|
||||||
|
label: t("auth.account.name_label"),
|
||||||
|
value: current_user.name,
|
||||||
|
required: true,
|
||||||
|
autocomplete: "name",
|
||||||
|
masked: false %>
|
||||||
|
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||||
|
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||||
|
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :current_password,
|
||||||
|
label: t("auth.account.current_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "current-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password,
|
||||||
|
label: t("auth.account.new_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password_confirmation,
|
||||||
|
label: t("auth.password_confirmation"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<% if @subscription.admin_comped_reason.present? %>
|
<% if @subscription.admin_comped_reason.present? %>
|
||||||
(<%= @subscription.admin_comped_reason %>)
|
(<%= @subscription.admin_comped_reason %>)
|
||||||
<% end %>.
|
<% end %>.
|
||||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
|
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -2,25 +2,27 @@
|
|||||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="card" style="max-width:480px">
|
<section class="auth-page">
|
||||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
<div class="card">
|
||||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||||
</p>
|
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||||
<% if logged_in? %>
|
</p>
|
||||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
<% if logged_in? %>
|
||||||
<% else %>
|
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||||
<p><%= raw t(
|
<% else %>
|
||||||
"auth.invitation.login_or_signup_html",
|
<p><%= raw t(
|
||||||
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
"auth.invitation.login_or_signup_html",
|
||||||
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
||||||
email: @invitation.email
|
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
||||||
) %></p>
|
email: @invitation.email
|
||||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
) %></p>
|
||||||
<% end %>
|
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
<% end %>
|
||||||
<%= t("auth.invitation.mobile_app_label") %>
|
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
<%= t("auth.invitation.mobile_app_label") %>
|
||||||
</p>
|
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<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>
|
<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>
|
</thead>
|
||||||
@@ -64,7 +65,8 @@
|
|||||||
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -75,7 +77,8 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<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>
|
<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>
|
</thead>
|
||||||
@@ -99,7 +102,8 @@
|
|||||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<%= raw t(
|
<%= raw t(
|
||||||
"legal.cookies.s4_2_p2_html",
|
"legal.cookies.s4_2_p2_html",
|
||||||
|
|||||||
@@ -22,24 +22,26 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
<table class="compare-table">
|
<div class="table-scroll">
|
||||||
<thead>
|
<table class="compare-table">
|
||||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
<tbody>
|
||||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
<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_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_matches") %></td><td>1</td><td>3</td><td>10</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_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_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_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_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_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_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>
|
<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>
|
||||||
</tbody>
|
<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>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||||
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<h1><%= t("auth.password_reset.title") %></h1>
|
<h1><%= t("auth.password_reset.title") %></h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||||
<%= hidden_field_tag :token, @token %>
|
<%= hidden_field_tag :token, @token %>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
required: true,
|
required: true,
|
||||||
minlength: 8,
|
minlength: 8,
|
||||||
autocomplete: "new-password" %>
|
autocomplete: "new-password" %>
|
||||||
|
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
name: "user[password_confirmation]",
|
name: "user[password_confirmation]",
|
||||||
id: "user_password_confirmation",
|
id: "user_password_confirmation",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px;text-align:center">
|
<div class="card" style="margin-top:24px;text-align:center">
|
||||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||||
<% if payments.any? %>
|
<% if payments.any? %>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="data billing-table">
|
<table class="data billing-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -58,10 +58,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle.addEventListener("click", function (e) {
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(menu.hidden);
|
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) {
|
document.addEventListener("click", function (e) {
|
||||||
if (!root.contains(e.target)) setOpen(false);
|
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">
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
<div class="nav-panel">
|
<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.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.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) %>
|
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||||
@@ -34,14 +43,12 @@
|
|||||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
<%= 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" %>
|
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="nav-lang">
|
|
||||||
<%= render "shared/language_switcher" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -77,8 +84,16 @@
|
|||||||
|
|
||||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
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) {
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
el.addEventListener("click", closeMenu);
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_email
|
def privacy_controller_email
|
||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ de:
|
|||||||
logout_success: Abgemeldet
|
logout_success: Abgemeldet
|
||||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
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_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert
|
password_updated: Passwort aktualisiert
|
||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
@@ -53,7 +56,7 @@ de:
|
|||||||
edit:
|
edit:
|
||||||
title: Passwort ändern
|
title: Passwort ändern
|
||||||
current_password_label: Aktuelles Passwort
|
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
|
confirm_password_label: Neues Passwort bestätigen
|
||||||
submit: Passwort speichern
|
submit: Passwort speichern
|
||||||
cancel: Abbrechen
|
cancel: Abbrechen
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ en:
|
|||||||
logout_success: Signed out
|
logout_success: Signed out
|
||||||
password_current_incorrect: Current password is incorrect
|
password_current_incorrect: Current password is incorrect
|
||||||
password_too_short: The new password must be at least 8 characters long
|
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_mismatch: Passwords do not match
|
||||||
password_updated: Password updated
|
password_updated: Password updated
|
||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
@@ -53,7 +56,7 @@ en:
|
|||||||
edit:
|
edit:
|
||||||
title: Change password
|
title: Change password
|
||||||
current_password_label: Current 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
|
confirm_password_label: Confirm new password
|
||||||
submit: Save password
|
submit: Save password
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ es:
|
|||||||
logout_success: Sesión cerrada
|
logout_success: Sesión cerrada
|
||||||
password_current_incorrect: La contraseña actual no es correcta
|
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_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_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada
|
password_updated: Contraseña actualizada
|
||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
@@ -53,7 +56,7 @@ es:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambiar contraseña
|
title: Cambiar contraseña
|
||||||
current_password_label: Contraseña actual
|
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
|
confirm_password_label: Confirma la nueva contraseña
|
||||||
submit: Guardar contraseña
|
submit: Guardar contraseña
|
||||||
cancel: Cancelar
|
cancel: Cancelar
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ fr:
|
|||||||
logout_success: Déconnecté
|
logout_success: Déconnecté
|
||||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
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_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_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour
|
password_updated: Mot de passe mis à jour
|
||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
@@ -53,7 +56,7 @@ fr:
|
|||||||
edit:
|
edit:
|
||||||
title: Changer le mot de passe
|
title: Changer le mot de passe
|
||||||
current_password_label: Mot de passe actuel
|
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
|
confirm_password_label: Confirmer le nouveau mot de passe
|
||||||
submit: Enregistrer le mot de passe
|
submit: Enregistrer le mot de passe
|
||||||
cancel: Annuler
|
cancel: Annuler
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ it:
|
|||||||
logout_success: Disconnesso
|
logout_success: Disconnesso
|
||||||
password_current_incorrect: Password attuale non corretta
|
password_current_incorrect: Password attuale non corretta
|
||||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
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_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata
|
password_updated: Password aggiornata
|
||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
@@ -53,7 +56,7 @@ it:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambia password
|
title: Cambia password
|
||||||
current_password_label: Password attuale
|
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
|
confirm_password_label: Conferma nuova password
|
||||||
submit: Salva password
|
submit: Salva password
|
||||||
cancel: Annulla
|
cancel: Annulla
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
de:
|
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: "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: "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:
|
club:
|
||||||
back_to_club: "← Verein"
|
back_to_club: "← Verein"
|
||||||
sport_label: Hauptsportart
|
sport_label: Hauptsportart
|
||||||
@@ -600,12 +622,29 @@ de:
|
|||||||
welcome_back: Willkommen zurück!
|
welcome_back: Willkommen zurück!
|
||||||
invalid_credentials: E-Mail oder Passwort ungültig
|
invalid_credentials: E-Mail oder Passwort ungültig
|
||||||
logged_out: Abgemeldet
|
logged_out: Abgemeldet
|
||||||
|
unauthorized: Nicht autorisiert
|
||||||
|
invalid_token: Ungültiges Token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
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.
|
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_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_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||||
password_mismatch: Die Passwörter stimmen nicht überein
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||||
|
accounts:
|
||||||
|
name_required: Der Name ist erforderlich
|
||||||
|
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_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:
|
replay:
|
||||||
download_unavailable: Download nicht verfügbar
|
download_unavailable: Download nicht verfügbar
|
||||||
not_available: Replay nicht verfügbar
|
not_available: Replay nicht verfügbar
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
en:
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
@@ -600,12 +617,29 @@ en:
|
|||||||
welcome_back: Welcome back!
|
welcome_back: Welcome back!
|
||||||
invalid_credentials: Invalid email or password
|
invalid_credentials: Invalid email or password
|
||||||
logged_out: Logged out
|
logged_out: Logged out
|
||||||
|
unauthorized: Unauthorized
|
||||||
|
invalid_token: Invalid token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
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.
|
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||||
password_min_length: Password must be at least 8 characters
|
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_same_as_current: New password must be different from the current password
|
||||||
password_mismatch: Passwords don't match
|
password_mismatch: Passwords don't match
|
||||||
password_updated: Password updated. You can now log in.
|
password_updated: Password updated. You can now log in.
|
||||||
|
accounts:
|
||||||
|
name_required: Name is required
|
||||||
|
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_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:
|
replay:
|
||||||
download_unavailable: Download not available
|
download_unavailable: Download not available
|
||||||
not_available: Replay not available
|
not_available: Replay not available
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
es:
|
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: "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: "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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
@@ -600,12 +622,29 @@ es:
|
|||||||
welcome_back: "¡Bienvenido de nuevo!"
|
welcome_back: "¡Bienvenido de nuevo!"
|
||||||
invalid_credentials: Correo o contraseña no válidos
|
invalid_credentials: Correo o contraseña no válidos
|
||||||
logged_out: Sesión cerrada
|
logged_out: Sesión cerrada
|
||||||
|
unauthorized: No autorizado
|
||||||
|
invalid_token: Token no válido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
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.
|
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_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_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||||
password_mismatch: Las contraseñas no coinciden
|
password_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||||
|
accounts:
|
||||||
|
name_required: El nombre es obligatorio
|
||||||
|
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_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:
|
replay:
|
||||||
download_unavailable: Descarga no disponible
|
download_unavailable: Descarga no disponible
|
||||||
not_available: Repetición no disponible
|
not_available: Repetición no disponible
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
fr:
|
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: "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: "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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Sport principal
|
sport_label: Sport principal
|
||||||
@@ -600,12 +622,29 @@ fr:
|
|||||||
welcome_back: Bon retour !
|
welcome_back: Bon retour !
|
||||||
invalid_credentials: E-mail ou mot de passe invalide
|
invalid_credentials: E-mail ou mot de passe invalide
|
||||||
logged_out: Déconnecté
|
logged_out: Déconnecté
|
||||||
|
unauthorized: Non autorisé
|
||||||
|
invalid_token: Jeton invalide
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
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.
|
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_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_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_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||||
|
accounts:
|
||||||
|
name_required: Le nom est obligatoire
|
||||||
|
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_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:
|
replay:
|
||||||
download_unavailable: Téléchargement non disponible
|
download_unavailable: Téléchargement non disponible
|
||||||
not_available: Replay non disponible
|
not_available: Replay non disponible
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
it:
|
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:
|
club:
|
||||||
back_to_club: "← Società"
|
back_to_club: "← Società"
|
||||||
sport_label: Sport principale
|
sport_label: Sport principale
|
||||||
@@ -600,12 +617,34 @@ it:
|
|||||||
welcome_back: Bentornato!
|
welcome_back: Bentornato!
|
||||||
invalid_credentials: Email o password non validi
|
invalid_credentials: Email o password non validi
|
||||||
logged_out: Disconnesso
|
logged_out: Disconnesso
|
||||||
|
unauthorized: Non autorizzato
|
||||||
|
invalid_token: Token non valido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
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.
|
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_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_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"
|
||||||
password_mismatch: Le password non coincidono
|
password_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata. Ora puoi accedere.
|
password_updated: Password aggiornata. Ora puoi accedere.
|
||||||
|
accounts:
|
||||||
|
name_required: Il nome è obbligatorio
|
||||||
|
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_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
|
||||||
|
password_updated: Password aggiornata.
|
||||||
|
password_update_failed: Impossibile aggiornare la password
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download non disponibile
|
download_unavailable: Download non disponibile
|
||||||
not_available: Replay non disponibile
|
not_available: Replay non disponibile
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ de:
|
|||||||
my_team: Mein Team
|
my_team: Mein Team
|
||||||
login: Anmelden
|
login: Anmelden
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
@@ -102,7 +103,7 @@ de:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Neues Passwort — Match Live TV"
|
meta_title: "Neues Passwort — Match Live TV"
|
||||||
title: Neues Passwort wählen
|
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
|
submit: Passwort speichern
|
||||||
back_to_login: Zurück zur Anmeldung
|
back_to_login: Zurück zur Anmeldung
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,6 +119,18 @@ de:
|
|||||||
signup_link: registrieren Sie sich
|
signup_link: registrieren Sie sich
|
||||||
mobile_app_label: "Mobile App:"
|
mobile_app_label: "Mobile App:"
|
||||||
open_in_app: Einladung in der App öffnen
|
open_in_app: Einladung in der App öffnen
|
||||||
|
account:
|
||||||
|
meta_title: "Dein Konto — Match Live TV"
|
||||||
|
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||||
|
title: Dein Konto
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Passwort ändern
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Rolle: %{role}"
|
||||||
|
current_password_label: Aktuelles Passwort
|
||||||
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
|
save_profile: Profil speichern
|
||||||
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ en:
|
|||||||
my_team: My team
|
my_team: My team
|
||||||
login: Log in
|
login: Log in
|
||||||
logout: Log out
|
logout: Log out
|
||||||
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
@@ -102,7 +103,7 @@ en:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "New password — Match Live TV"
|
meta_title: "New password — Match Live TV"
|
||||||
title: Choose a new password
|
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
|
submit: Save password
|
||||||
back_to_login: Back to login
|
back_to_login: Back to login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,6 +119,18 @@ en:
|
|||||||
signup_link: sign up
|
signup_link: sign up
|
||||||
mobile_app_label: "Mobile app:"
|
mobile_app_label: "Mobile app:"
|
||||||
open_in_app: Open invitation in the app
|
open_in_app: Open invitation in the app
|
||||||
|
account:
|
||||||
|
meta_title: "Your account — Match Live TV"
|
||||||
|
meta_description: Manage your Match Live TV account name and password.
|
||||||
|
title: Your account
|
||||||
|
profile_heading: Profile
|
||||||
|
password_heading: Change password
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Role: %{role}"
|
||||||
|
current_password_label: Current password
|
||||||
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
|
save_profile: Save profile
|
||||||
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ es:
|
|||||||
my_team: Mi equipo
|
my_team: Mi equipo
|
||||||
login: Acceder
|
login: Acceder
|
||||||
logout: Salir
|
logout: Salir
|
||||||
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
@@ -102,7 +103,7 @@ es:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nueva contraseña — Match Live TV"
|
meta_title: "Nueva contraseña — Match Live TV"
|
||||||
title: Elige una nueva contraseña
|
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
|
submit: Guardar contraseña
|
||||||
back_to_login: Volver al inicio de sesión
|
back_to_login: Volver al inicio de sesión
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,6 +119,18 @@ es:
|
|||||||
signup_link: regístrate
|
signup_link: regístrate
|
||||||
mobile_app_label: "App móvil:"
|
mobile_app_label: "App móvil:"
|
||||||
open_in_app: Abrir invitación en la app
|
open_in_app: Abrir invitación en la app
|
||||||
|
account:
|
||||||
|
meta_title: "Tu cuenta — Match Live TV"
|
||||||
|
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||||
|
title: Tu cuenta
|
||||||
|
profile_heading: Perfil
|
||||||
|
password_heading: Cambiar contraseña
|
||||||
|
name_label: Nombre
|
||||||
|
role_label: "Rol: %{role}"
|
||||||
|
current_password_label: Contraseña actual
|
||||||
|
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:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fr:
|
|||||||
my_team: Mon équipe
|
my_team: Mon équipe
|
||||||
login: Connexion
|
login: Connexion
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
@@ -102,7 +103,7 @@ fr:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nouveau mot de passe — Match Live TV"
|
meta_title: "Nouveau mot de passe — Match Live TV"
|
||||||
title: Choisissez un nouveau mot de passe
|
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
|
submit: Enregistrer le mot de passe
|
||||||
back_to_login: Retour à la connexion
|
back_to_login: Retour à la connexion
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,6 +119,18 @@ fr:
|
|||||||
signup_link: inscrivez-vous
|
signup_link: inscrivez-vous
|
||||||
mobile_app_label: "Application mobile :"
|
mobile_app_label: "Application mobile :"
|
||||||
open_in_app: Ouvrir l'invitation dans l'application
|
open_in_app: Ouvrir l'invitation dans l'application
|
||||||
|
account:
|
||||||
|
meta_title: "Votre compte — Match Live TV"
|
||||||
|
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||||
|
title: Votre compte
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Changer le mot de passe
|
||||||
|
name_label: Nom
|
||||||
|
role_label: "Rôle : %{role}"
|
||||||
|
current_password_label: Mot de passe actuel
|
||||||
|
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:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ it:
|
|||||||
my_team: La mia squadra
|
my_team: La mia squadra
|
||||||
login: Accedi
|
login: Accedi
|
||||||
logout: Esci
|
logout: Esci
|
||||||
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
@@ -102,7 +103,7 @@ it:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nuova password — Match Live TV"
|
meta_title: "Nuova password — Match Live TV"
|
||||||
title: Scegli una nuova password
|
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
|
submit: Salva password
|
||||||
back_to_login: Torna al login
|
back_to_login: Torna al login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,6 +119,18 @@ it:
|
|||||||
signup_link: registrati
|
signup_link: registrati
|
||||||
mobile_app_label: "App mobile:"
|
mobile_app_label: "App mobile:"
|
||||||
open_in_app: Apri invito nell'app
|
open_in_app: Apri invito nell'app
|
||||||
|
account:
|
||||||
|
meta_title: "Il tuo account — Match Live TV"
|
||||||
|
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||||
|
title: Il tuo account
|
||||||
|
profile_heading: Profilo
|
||||||
|
password_heading: Cambia password
|
||||||
|
name_label: Nome
|
||||||
|
role_label: "Ruolo: %{role}"
|
||||||
|
current_password_label: Password attuale
|
||||||
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
|
save_profile: Salva profilo
|
||||||
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
|||||||
post "auth/logout", to: "auth#logout"
|
post "auth/logout", to: "auth#logout"
|
||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
post "auth/password/forgot", to: "auth#forgot_password"
|
||||||
|
get "account", to: "accounts#show"
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
patch "account/password", to: "accounts#password"
|
||||||
get "sports", to: "sports#index"
|
get "sports", to: "sports#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
@@ -163,6 +167,10 @@ Rails.application.routes.draw do
|
|||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||||
patch "password/reset", to: "password_resets#update"
|
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
|
get "clubs/new", to: "clubs#new", as: :new_club
|
||||||
post "clubs", to: "clubs#create"
|
post "clubs", to: "clubs#create"
|
||||||
get "clubs/:id", to: "clubs#show", as: :club
|
get "clubs/:id", to: "clubs#show", as: :club
|
||||||
|
|||||||
+4
-4
@@ -1,18 +1,18 @@
|
|||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
|
||||||
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
||||||
a.password = "admin"
|
a.password = "AdminPass123"
|
||||||
end
|
end
|
||||||
|
|
||||||
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
u.name = "Coach Demo"
|
u.name = "Coach Demo"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "coach"
|
u.role = "coach"
|
||||||
end
|
end
|
||||||
|
|
||||||
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
||||||
u.name = "Admin"
|
u.name = "Admin"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "admin"
|
u.role = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
|||||||
m.phase = "Semifinale"
|
m.phase = "Semifinale"
|
||||||
end
|
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}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+107
-16
@@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
body.nav-menu-open .site-chrome {
|
body.nav-menu-open .site-chrome {
|
||||||
z-index: 1300;
|
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 {
|
.nav-backdrop {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.nav-panel {
|
.nav-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 72px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
padding-top: calc(72px + env(safe-area-inset-top, 0px));
|
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
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 > a,
|
||||||
.nav-panel .nav-link-item {
|
.nav-panel .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 0;
|
||||||
margin-top: 24px;
|
margin-top: 8px;
|
||||||
padding: 24px 0 0;
|
padding: 0;
|
||||||
border-top: 1px solid #252530;
|
border-top: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.nav-actions .nav-link-item {
|
.nav-actions .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
.nav-lang {
|
||||||
margin-left: 0;
|
margin: 0;
|
||||||
margin-top: 4px;
|
|
||||||
justify-content: flex-end;
|
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;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 20px;
|
gap: 8px 20px;
|
||||||
}
|
}
|
||||||
|
.nav-mobile-head {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-lang {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
|
order: 1;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
.nav-backdrop { display: none !important; }
|
.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; }
|
.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 { flex-direction: column; }
|
||||||
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
.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; }
|
.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; }
|
.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; }
|
.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 { flex: 1 1 100%; margin-top: 4px; }
|
||||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
.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, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
.compare-table th { color: #aaa; font-weight: 600; }
|
.compare-table th { color: #aaa; font-weight: 600; }
|
||||||
.billing-documents h2 { margin-top: 0; }
|
.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; }
|
.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 { 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 h2:first-child { margin-top: 0; }
|
||||||
.seo-prose p { margin: 0 0 14px; }
|
.seo-prose p { margin: 0 0 14px; }
|
||||||
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
||||||
.seo-prose a { color: #e53935; }
|
.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; }
|
.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-list { max-width: 720px; margin: 0 auto; }
|
||||||
.faq-item {
|
.faq-item {
|
||||||
@@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.legal-doc a { color: #e53935; }
|
.legal-doc a { color: #e53935; }
|
||||||
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
||||||
.legal-back { margin-top: 32px; }
|
.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, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||||
.legal-table th { background: #14141c; color: #ccc; }
|
.legal-table th { background: #14141c; color: #ccc; }
|
||||||
.legal-accept {
|
.legal-accept {
|
||||||
@@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.auth-forgot a { color: #e53935; }
|
.auth-forgot a { color: #e53935; }
|
||||||
table.data { width: 100%; border-collapse: collapse; }
|
table.data { width: 100%; border-collapse: collapse; }
|
||||||
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
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 {
|
input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 ".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")
|
||||||
|
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
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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(:auth_headers) do
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
|
token = JSON.parse(response.body).fetch("access_token")
|
||||||
|
{ "Authorization" => "Bearer #{token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/v1/account" do
|
||||||
|
it "returns the current user profile" do
|
||||||
|
get "/api/v1/account", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires authentication" do
|
||||||
|
get "/api/v1/account"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account" do
|
||||||
|
it "updates the name" do
|
||||||
|
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
|
||||||
|
expect(user.reload.name).to eq("Nuovo Nome")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a blank name" do
|
||||||
|
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account/password" do
|
||||||
|
it "changes the password with the current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
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
|
||||||
|
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.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
|
||||||
|
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.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
|
||||||
|
it "always returns ok and sends mail when the user exists" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the same message for unknown emails" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||||
|
}.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
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Auth API", type: :request do
|
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
|
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(response).to have_http_status(:ok)
|
||||||
expect(JSON.parse(response.body)).to have_key("access_token")
|
expect(JSON.parse(response.body)).to have_key("access_token")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::YoutubeRelay do
|
||||||
|
describe ".youtube_ffmpeg_args (private)" do
|
||||||
|
def args(mode, url)
|
||||||
|
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
|
||||||
|
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("aac")
|
||||||
|
expect(cmd.join(" ")).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||||
|
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Android App Links / Digital Asset Links
|
||||||
|
|
||||||
|
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
|
||||||
|
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
|
||||||
|
|
||||||
|
Package: `com.matchlivetv.match_live_tv`
|
||||||
|
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
|
||||||
|
|
||||||
|
Nel JSON ci sono:
|
||||||
|
|
||||||
|
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
|
||||||
|
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dell’app)
|
||||||
|
|
||||||
|
Relazioni richieste da Play per la condivisione credenziali:
|
||||||
|
|
||||||
|
- `delegate_permission/common.handle_all_urls`
|
||||||
|
- `delegate_permission/common.get_login_creds`
|
||||||
|
|
||||||
|
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
|
||||||
|
## Deploy edge (senza rebuild Rails)
|
||||||
|
|
||||||
|
Sul server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/matchlivetv/infra
|
||||||
|
# dopo git pull del repo
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
|
||||||
|
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifica Google:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi in Play Console → Link diretti → ripeti i controlli dominio.
|
||||||
@@ -47,7 +47,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
|
|||||||
|
|
||||||
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
||||||
2. Completa i passi → **Camera**
|
2. Completa i passi → **Camera**
|
||||||
3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
|
3. Il telefono invia RTMP a MediaMTX (con overlay tabellone già nel flusso, se attivo); il server avvia **automaticamente** il relay RTMPS verso YouTube (`Streams::YoutubeRelay`) — nessun intervento manuale
|
||||||
|
|
||||||
## Sviluppo locale
|
## Sviluppo locale
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
|
|||||||
| YouTube disabilitato | Premium Light o Full |
|
| YouTube disabilitato | Premium Light o Full |
|
||||||
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
||||||
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
||||||
| Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
|
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
|
||||||
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
|
| YouTube «In programma» / copertina | Entro ~20–90s il server avvia il relay RTMPS e attiva la broadcast quando l’ingest è pronto; in pausa MediaMTX manda la slate. |
|
||||||
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
|
| YouTube resta in «waiting» | Log `log/youtube_relay_*.log` in **sidekiq**; poll `StreamPublisherSyncJob`. Attivazione ritenta ~6 min. |
|
||||||
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
||||||
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
||||||
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
||||||
|
|||||||
+25
-19
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-06-14
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
|
|||||||
### Flusso ingest
|
### Flusso ingest
|
||||||
|
|
||||||
```
|
```
|
||||||
App Android (RTMP 720p, AAC mono)
|
App (Android/iOS) RTMP 720p AAC mono
|
||||||
|
+ overlay GPU (tabellone/watermark) se overlay_kind ≠ none
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
|
MediaMTX :1935 — path live/match_{uuid}
|
||||||
│
|
|
||||||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
|
||||||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
|
||||||
│
|
│
|
||||||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||||||
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay in Sidekiq
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
|
||||||
```
|
```
|
||||||
|
|
||||||
| Componente | Ruolo |
|
| Componente | Ruolo |
|
||||||
|------------|--------|
|
|------------|--------|
|
||||||
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
|
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
|
||||||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||||||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
| Overlay tabellone | **App nativa** (non più ricodifica server) |
|
||||||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
|
||||||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
|
||||||
|
|
||||||
|
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
### Pausa e continuità
|
### Pausa e continuità
|
||||||
|
|
||||||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||||||
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
||||||
3. YouTube relay continua sulla stessa uscita (vede slate).
|
3. Se attivo, YouTube relay continua sulla stessa uscita (vede slate).
|
||||||
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
||||||
|
|
||||||
**Recording inline MediaMTX disabilitato** (`record: false` su path live): il recorder al switch slate→camera distruggeva il muxer HLS. I replay usano job dedicato (vedi sotto).
|
**Recording:** path creato con `record: false`; acceso solo con publisher online e entitlement (`PublisherSync`). I replay usano job dedicato (vedi sotto).
|
||||||
|
|
||||||
### Pagina live web
|
### Pagina live web
|
||||||
|
|
||||||
- URL: `/live/:session_id`
|
- URL: `/live/:session_id`
|
||||||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}_air/index.m3u8`
|
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}/index.m3u8`
|
||||||
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
|
|||||||
│
|
│
|
||||||
Sidekiq ◄── Redis ◄──────┘
|
Sidekiq ◄── Redis ◄──────┘
|
||||||
│
|
│
|
||||||
├── HealthMonitorJob, UploadJob, overlay refresh
|
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
|
||||||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
|
||||||
└── PublishToYoutubeJob, purge replay, ecc.
|
└── PublishToYoutubeJob, purge replay, ecc.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,10 +397,12 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pause, overlay, HLS.js |
|
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
||||||
|
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||||
|
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
||||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Grafica in stream |
|
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
||||||
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
||||||
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
||||||
|
|
||||||
@@ -412,5 +416,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube legge `mediamtx:8888` diretto |
|
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
|
||||||
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||||
|
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||||
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
|||||||
@@ -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.
|
||||||
+49
-34
@@ -1,23 +1,25 @@
|
|||||||
# Diretta live — architettura MediaMTX
|
# Diretta live — architettura MediaMTX
|
||||||
|
|
||||||
## Idea (non è folle)
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
|
|
||||||
|
## Idea
|
||||||
|
|
||||||
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
||||||
|
|
||||||
1. **Telefono in onda** → publisher RTMP (camera).
|
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
|
||||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||||
|
|
||||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
|
||||||
|
|
||||||
## Componenti
|
## Componenti
|
||||||
|
|
||||||
| Pezzo | Ruolo |
|
| Pezzo | Ruolo |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
|
||||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
|
||||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
|
||||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
|
||||||
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
||||||
|
|
||||||
## Pausa e continuità
|
## Pausa e continuità
|
||||||
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
|
|||||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||||
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
||||||
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
||||||
6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX.
|
6. **YouTube** → se attivo, il relay ffmpeg continua sulla stessa uscita MediaMTX (vede slate in pausa).
|
||||||
|
|
||||||
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
||||||
|
|
||||||
**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`).
|
**Recording**: sul path live parte `record: false`; viene acceso via API solo con publisher online e entitlement (`PublisherSync`). I replay finiscono in Garage con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||||
|
|
||||||
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
||||||
|
|
||||||
## Infrastruttura vs browser
|
## Infrastruttura vs browser
|
||||||
|
|
||||||
```
|
```
|
||||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
Telefono RTMP (camera + overlay app)
|
||||||
│
|
│
|
||||||
publisher ON ├─► camera (H.264/AAC)
|
▼
|
||||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
MediaMTX path live/match_{uuid}
|
||||||
│
|
│
|
||||||
├─► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
|
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
|
||||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
publisher OFF → alwaysAvailable → /slates/offline.mp4
|
||||||
|
│
|
||||||
|
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
|
||||||
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
|
||||||
```
|
```
|
||||||
|
|
||||||
| Responsabilità | Dove |
|
| Responsabilità | Dove |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
|
||||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
| Continuità YouTube | **`Streams::YoutubeRelay`** (ffmpeg in Sidekiq) |
|
||||||
|
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) + UI web |
|
||||||
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
||||||
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
||||||
|
|
||||||
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
||||||
|
|
||||||
|
URL HLS pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`StreamSession#effective_hls_path_name` = path camera, **non** più `*_air`).
|
||||||
|
|
||||||
### Player web — anti-scatti (HLS.js)
|
### Player web — anti-scatti (HLS.js)
|
||||||
|
|
||||||
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||||
@@ -67,27 +77,32 @@ Configurazione in `backend/app/views/public/live/show.html.erb`:
|
|||||||
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
||||||
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
||||||
|
|
||||||
### Grafica nel video (overlay server)
|
### Grafica nel video (overlay)
|
||||||
|
|
||||||
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
|
**Non** c’è più un relay server `Streams::OverlayRelay` che ricodifica con PNG.
|
||||||
|
|
||||||
```
|
Il tabellone è composito **sul telefono** prima dell’RTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
|
||||||
Telefono → live/match_{uuid} (grezzo)
|
|
||||||
└─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s)
|
|
||||||
└─► live/match_{uuid}_air
|
|
||||||
├─► HLS (sito, anche fullscreen)
|
|
||||||
└─► YouTube relay (-c copy)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
|
||||||
- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco
|
2. `OverlayRenderer` / canvas → bitmap
|
||||||
- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati
|
3. Filtro GPU sul encoder RTMP
|
||||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
|
||||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
|
||||||
|
|
||||||
|
### Ruolo di ffmpeg oggi
|
||||||
|
|
||||||
|
| Quando | Cosa | Peso CPU |
|
||||||
|
|--------|------|----------|
|
||||||
|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
|
||||||
|
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
|
||||||
|
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||||
|
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||||
|
|
||||||
|
Non esiste più la ricodifica H.264 server-side dell’overlay (era il carico principale).
|
||||||
|
|
||||||
### Punteggio (persistenza)
|
### Punteggio (persistenza)
|
||||||
|
|
||||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna l’overlay locale in camera.
|
||||||
|
|
||||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
||||||
|
|
||||||
Esigenze custom: contatto `info@matchlive.it`.
|
Esigenze custom: contatto `info@matchlivetv.it`.
|
||||||
|
|
||||||
## Sito
|
## Sito
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
||||||
|
|
||||||
|
**Importante (2026-07):** l’overlay video è composito **solo sull’app nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Panoramica
|
## Panoramica
|
||||||
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
|
|||||||
|
|
||||||
### Layout app mobile (Android e iOS)
|
### Layout app mobile (Android e iOS)
|
||||||
|
|
||||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (nessuna ricodifica server aggiuntiva):
|
||||||
|
|
||||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||||
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
||||||
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
||||||
|
|
||||||
|
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
|
||||||
|
|
||||||
Elementi grafici (`OverlayCanvasRenderer`):
|
Elementi grafici (`OverlayCanvasRenderer`):
|
||||||
|
|
||||||
| Elemento | Quando è attivo |
|
| Elemento | Quando è attivo |
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
# YouTube Live — verificato ✅ (5 giu 2026)
|
# YouTube Live — checklist operativa
|
||||||
|
|
||||||
## Test completato automaticamente
|
**Aggiornato:** 2026-07-29
|
||||||
|
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
|
||||||
|
|
||||||
Pipeline end-to-end **funzionante** sul server di produzione:
|
## Verifica rapida su server
|
||||||
|
|
||||||
| Step | Esito |
|
|
||||||
|------|--------|
|
|
||||||
| Setup broadcast + stream_key | ✅ |
|
|
||||||
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
|
|
||||||
| Copertina / tabellone su YouTube | ✅ |
|
|
||||||
| RTMP simulato (test pattern) | ✅ |
|
|
||||||
| Broadcast `lifecycle=live` | ✅ |
|
|
||||||
|
|
||||||
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
|
|
||||||
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
|
|
||||||
|
|
||||||
## APK da installare sul telefono
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sul PC (già compilato):
|
|
||||||
mobile/build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
|
|
||||||
```
|
|
||||||
|
|
||||||
Installa sul Redmi (sostituisce versioni precedenti).
|
|
||||||
|
|
||||||
## Avvio diretta dall’app (2 minuti)
|
|
||||||
|
|
||||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
|
||||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
|
|
||||||
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
|
|
||||||
|
|
||||||
## Test server senza telefono
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh eminux@192.168.1.146
|
ssh eminux@192.168.1.146
|
||||||
docker exec infra-rails-1 bin/rails youtube:e2e
|
docker exec infra-rails-1 bin/rails youtube:e2e # se il task è ancora presente
|
||||||
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
||||||
|
docker logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Avvio diretta dall’app
|
||||||
|
|
||||||
|
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||||
|
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
|
||||||
|
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando l’app pubblica RTMP con overlay)
|
||||||
|
|
||||||
## Se qualcosa non va
|
## Se qualcosa non va
|
||||||
|
|
||||||
| Sintomo | Cosa fare |
|
| Sintomo | Cosa fare |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
||||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
|
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
|
||||||
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: permessi camera / messaggio «Connessione RTMP»; relay YouTube non parte finché MediaMTX non vede il publisher |
|
||||||
|
| YouTube resta in waiting | Log relay in sidekiq; `StreamPublisherSyncJob` + activate retry |
|
||||||
|
|
||||||
## Fix deployati (server)
|
## Note storiche
|
||||||
|
|
||||||
- Retry setup broadcast su rate limit (fino a 15 tentativi)
|
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dall’overlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
|
||||||
- Un solo job setup per sessione (lock Redis)
|
|
||||||
- Throttle API YouTube globale
|
|
||||||
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
|
|
||||||
- Activate solo con overlay attivo + broadcast pronta
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
|
|||||||
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
||||||
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
||||||
|
|
||||||
Flusso tecnico: telefono → RTMP MediaMTX → (relay ffmpeg) → YouTube Live.
|
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,5 +85,5 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
## 6. Produzione
|
## 6. Produzione
|
||||||
|
|
||||||
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
||||||
- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
|
- Il relay ffmpeg (`Streams::YoutubeRelay`) gira nel container **sidekiq** (`YOUTUBE_RELAY_WORKER=1`), non su MediaMTX `runOnReady`.
|
||||||
- Apri RTMP **1935** sul router verso il server.
|
- Apri RTMP **1935** sul router verso il server.
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||||
|
|
||||||
|
**Stato:** piano / design — non implementato
|
||||||
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
|
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||||
|
|
||||||
|
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scenario
|
||||||
|
|
||||||
|
### Problema di prodotto
|
||||||
|
|
||||||
|
Match Live TV disaccoppia il telefono da YouTube:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Telefono (rete mobile instabile)
|
||||||
|
│ RTMP
|
||||||
|
▼
|
||||||
|
MediaMTX (sempre attivo, slate / alwaysAvailable)
|
||||||
|
│
|
||||||
|
├── HLS → sito
|
||||||
|
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
|
||||||
|
```
|
||||||
|
|
||||||
|
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
|
||||||
|
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dall’app mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
|
||||||
|
|
||||||
|
### Problema di capacità
|
||||||
|
|
||||||
|
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
|
||||||
|
Il carico scala con **N processi**, non con gli spettatori HLS.
|
||||||
|
|
||||||
|
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
|
||||||
|
|
||||||
|
| Live YT contemporanee | Situazione attesa |
|
||||||
|
|-----------------------|-------------------|
|
||||||
|
| 1–4 | Comodo |
|
||||||
|
| 5–8 | Teso (CPU/RAM, latenza job Sidekiq) |
|
||||||
|
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
|
||||||
|
|
||||||
|
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
|
||||||
|
|
||||||
|
### Cosa NON è questo piano
|
||||||
|
|
||||||
|
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
|
||||||
|
- Non sposta l’ingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
|
||||||
|
- Non è autoscaling del monolite Docker Compose intero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Principio di progettazione
|
||||||
|
|
||||||
|
| Componente | Dove resta | Perché |
|
||||||
|
|------------|------------|--------|
|
||||||
|
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per l’app; continuità YouTube via slate |
|
||||||
|
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
|
||||||
|
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
|
||||||
|
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
|
||||||
|
|
||||||
|
Formula mentale:
|
||||||
|
|
||||||
|
```text
|
||||||
|
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura target (overflow)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ telefoni RTMP ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Dedicated (sempre on) │
|
||||||
|
│ MediaMTX · Rails · DB │
|
||||||
|
│ Redis · Garage · edge │
|
||||||
|
│ Sidekiq "core" │
|
||||||
|
│ (+ pochi relay locali) │
|
||||||
|
└───────────┬─────────────┘
|
||||||
|
│
|
||||||
|
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
[relay-local] [overflow-1] [overflow-N]
|
||||||
|
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
|
||||||
|
YOUTUBE_RELAY=1 a ore a ore
|
||||||
|
│ │ │
|
||||||
|
└───────────────┴───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
YouTube RTMPS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ruolo delle VM overflow
|
||||||
|
|
||||||
|
Immagine minimale (o Compose snello):
|
||||||
|
|
||||||
|
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
|
||||||
|
- `ffmpeg` disponibile
|
||||||
|
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
|
||||||
|
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
|
||||||
|
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
|
||||||
|
|
||||||
|
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Colli di bottiglia (cosa può rompersi comunque)
|
||||||
|
|
||||||
|
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
|
||||||
|
|
||||||
|
### 4.1 MediaMTX sul dedicated (alto)
|
||||||
|
|
||||||
|
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
|
||||||
|
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
|
||||||
|
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
|
||||||
|
|
||||||
|
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
|
||||||
|
|
||||||
|
### 4.2 Uplink Internet del dedicated (alto)
|
||||||
|
|
||||||
|
- Telefoni → dedicated (ingresso RTMP).
|
||||||
|
- Dedicated → YouTube **se** i relay restano locali.
|
||||||
|
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
|
||||||
|
|
||||||
|
Attenzione al verso:
|
||||||
|
|
||||||
|
| Dove gira il relay | Traffico tipico |
|
||||||
|
|--------------------|-----------------|
|
||||||
|
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
|
||||||
|
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
|
||||||
|
|
||||||
|
Se l’uplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
|
||||||
|
Vantaggio reale solo se:
|
||||||
|
|
||||||
|
- l’uplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
|
||||||
|
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e l’uscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
|
||||||
|
|
||||||
|
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
|
||||||
|
|
||||||
|
### 4.3 Redis / lock owner (medio)
|
||||||
|
|
||||||
|
Oggi:
|
||||||
|
|
||||||
|
- `youtube_relay:pid:%s` — PID locale al worker
|
||||||
|
- `youtube_relay:owner:%s` — `HOSTNAME` del worker
|
||||||
|
- `running?` considera attivo un owner remoto se TTL > 30s
|
||||||
|
|
||||||
|
Con più worker:
|
||||||
|
|
||||||
|
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
|
||||||
|
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
|
||||||
|
- Kill del PID funziona solo sulla macchina owner (`/proc`).
|
||||||
|
|
||||||
|
Serve disciplina di coda / routing (§5.3).
|
||||||
|
|
||||||
|
### 4.4 Cold start VM (medio)
|
||||||
|
|
||||||
|
Creare una CPX/CCX Hetzner: ordine di **30–90+ secondi** (API + boot + pull image + Sidekiq ready).
|
||||||
|
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
|
||||||
|
|
||||||
|
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
|
||||||
|
|
||||||
|
### 4.5 YouTube / account / quote (basso–medio, esterno)
|
||||||
|
|
||||||
|
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
|
||||||
|
- Non risolvibili con più CPU; restano nel runbook ops esistente.
|
||||||
|
|
||||||
|
### 4.6 Post-process replay (basso in live, alto a fine giornata)
|
||||||
|
|
||||||
|
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
|
||||||
|
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
|
||||||
|
|
||||||
|
### 4.7 Costo e idle (operativo)
|
||||||
|
|
||||||
|
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complessità di implementazione
|
||||||
|
|
||||||
|
### 5.1 Rete: come le VM leggono MediaMTX
|
||||||
|
|
||||||
|
Opzioni (sceglierne **una** in fase 0):
|
||||||
|
|
||||||
|
| Opzione | Pro | Contro |
|
||||||
|
|---------|-----|--------|
|
||||||
|
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
|
||||||
|
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
|
||||||
|
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
|
||||||
|
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
|
||||||
|
|
||||||
|
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
|
||||||
|
|
||||||
|
Variabili da prevedere sulle VM:
|
||||||
|
|
||||||
|
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
|
||||||
|
- `MEDIAMTX_HLS_URL` → idem
|
||||||
|
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
|
||||||
|
|
||||||
|
### 5.2 Accesso Postgres e segreti
|
||||||
|
|
||||||
|
Worker Sidekiq Rails oggi necessitano:
|
||||||
|
|
||||||
|
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico all’inizio)
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `SECRET_KEY_BASE` / credenziali YouTube via DB
|
||||||
|
- stessi env di produzione (ridotti)
|
||||||
|
|
||||||
|
Complessità: ogni overflow è un **nodo fidato** della rete app.
|
||||||
|
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
|
||||||
|
|
||||||
|
### 5.3 Code Sidekiq e affinità relay
|
||||||
|
|
||||||
|
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
|
||||||
|
|
||||||
|
Per multi-host serve esplicitare:
|
||||||
|
|
||||||
|
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
|
||||||
|
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores−1).
|
||||||
|
3. **Scheduling**:
|
||||||
|
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
|
||||||
|
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
|
||||||
|
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sull’owner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
|
||||||
|
|
||||||
|
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
|
||||||
|
|
||||||
|
### 5.4 Scale-out / scale-in (lifecycle VM)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
|
||||||
|
↘ fallisce → alert ops, non spegnere dedicated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scale-out trigger (esempi):**
|
||||||
|
|
||||||
|
- `youtube_relay` depth > 0 per >60s **oppure**
|
||||||
|
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
|
||||||
|
|
||||||
|
**Scale-in trigger:**
|
||||||
|
|
||||||
|
- `relay_attivi` sulle VM overflow = 0 per >15–30 min **e**
|
||||||
|
- fuori dalla finestra “weekend caldo” (opzionale)
|
||||||
|
|
||||||
|
**Warm pool:** sabato 8:00 → min 1–2 VM già up; domenica 23:00 → min 0.
|
||||||
|
|
||||||
|
### 5.5 Idempotenza e split-brain
|
||||||
|
|
||||||
|
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
|
||||||
|
|
||||||
|
Mitigazioni:
|
||||||
|
|
||||||
|
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
|
||||||
|
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
|
||||||
|
- Un solo writer della broadcast activate
|
||||||
|
|
||||||
|
### 5.6 Observability
|
||||||
|
|
||||||
|
Senza metriche l’overflow è cieco. Minimo:
|
||||||
|
|
||||||
|
| Metrica | Dove |
|
||||||
|
|---------|------|
|
||||||
|
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
|
||||||
|
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
|
||||||
|
| Profondità coda `youtube_relay` | Sidekiq API |
|
||||||
|
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
|
||||||
|
| VM overflow accese, € stimati | tag Hetzner + cron report |
|
||||||
|
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
|
||||||
|
|
||||||
|
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
|
||||||
|
|
||||||
|
### 5.7 Sicurezza
|
||||||
|
|
||||||
|
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
|
||||||
|
- Nessun SSH password; chiave o console Hetzner
|
||||||
|
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
|
||||||
|
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Decisioni aperte (bloccare prima del codice)
|
||||||
|
|
||||||
|
| # | Domanda | Impatto |
|
||||||
|
|---|---------|---------|
|
||||||
|
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
|
||||||
|
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
|
||||||
|
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
|
||||||
|
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
|
||||||
|
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
|
||||||
|
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
|
||||||
|
|
||||||
|
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dimensionamento indicativo
|
||||||
|
|
||||||
|
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||||
|
|
||||||
|
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|
||||||
|
|----------------|-------------------|-----------------|------|
|
||||||
|
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
|
||||||
|
| 10–15 | soft cap 4–6 | 1× VM 4–8 vCPU | Warm consigliato in giornata evento |
|
||||||
|
| 20–30 | soft cap 4–6 | 2–4× VM | MediaMTX e uplink diventano critici |
|
||||||
|
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
|
||||||
|
|
||||||
|
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Piano a fasi (pronti a reagire, non a over-build)
|
||||||
|
|
||||||
|
### Fase 0 — Misura e soglie (1–2 giorni ops)
|
||||||
|
|
||||||
|
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
|
||||||
|
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
|
||||||
|
- [ ] Chiudere **D1** (rete)
|
||||||
|
- [ ] Load test controllato: 8–12 relay solo slate (senza telefoni) per vedere dove rompe
|
||||||
|
|
||||||
|
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
|
||||||
|
|
||||||
|
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
|
||||||
|
|
||||||
|
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
|
||||||
|
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
|
||||||
|
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
|
||||||
|
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
|
||||||
|
- [ ] Alert se VM overflow up da >3h con 0 owner keys
|
||||||
|
|
||||||
|
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
|
||||||
|
|
||||||
|
### Fase 2 — Routing coda robusto
|
||||||
|
|
||||||
|
- [ ] Coda `youtube_relay` isolata
|
||||||
|
- [ ] Heartbeat owner Redis
|
||||||
|
- [ ] Stop/ensure sticky all’owner
|
||||||
|
- [ ] Cap per worker + requeue
|
||||||
|
|
||||||
|
### Fase 3 — Autoscaling controllato
|
||||||
|
|
||||||
|
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
|
||||||
|
- [ ] Scale-in solo se idle e fuori warm window
|
||||||
|
- [ ] Budget mensile + kill switch
|
||||||
|
|
||||||
|
### Fase 4 — Solo se necessario
|
||||||
|
|
||||||
|
- Secondo MediaMTX / sharding
|
||||||
|
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||||
|
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Runbook di reazione (anche prima dell’autoscaler)
|
||||||
|
|
||||||
|
### Sintomo: YouTube “in programma” / niente video con molte live
|
||||||
|
|
||||||
|
1. `docker stats` / CPU Sidekiq sul dedicated
|
||||||
|
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
|
||||||
|
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
|
||||||
|
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
|
||||||
|
|
||||||
|
### Sintomo: live YT che si spezza solo sulle VM Cloud
|
||||||
|
|
||||||
|
1. RTT e packet loss dedicated↔VM
|
||||||
|
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
|
||||||
|
3. Controllare doppio owner / due ffmpeg sulla stessa key
|
||||||
|
|
||||||
|
### Sintomo: bolletta Cloud anomala
|
||||||
|
|
||||||
|
1. Lista server con label `matchlivetv-overflow`
|
||||||
|
2. Destroy idle
|
||||||
|
3. Abbassare warm pool
|
||||||
|
|
||||||
|
### Sintomo: job replay morti dopo scale-in
|
||||||
|
|
||||||
|
1. Verificare che overflow **non** consumi coda `default` / `recordings`
|
||||||
|
2. Riprocessare dead set Sidekiq
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Criteri di successo
|
||||||
|
|
||||||
|
| Criterio | Misura |
|
||||||
|
|----------|--------|
|
||||||
|
| Picco gestito | ≥15 live YT contemporanee stabili in test |
|
||||||
|
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
|
||||||
|
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
|
||||||
|
| Ops | Runbook Fase 1 eseguibile in <10 min da operatore |
|
||||||
|
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Riepilogo esecutivo
|
||||||
|
|
||||||
|
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non l’idea MediaMTX.
|
||||||
|
Hetzner Cloud è la leva economica giusta se:
|
||||||
|
|
||||||
|
1. scalate **solo** i worker `YoutubeRelay`,
|
||||||
|
2. tenete MediaMTX caldo sul dedicated,
|
||||||
|
3. risolvete **rete privata** dedicated↔Cloud,
|
||||||
|
4. partite da **warm pool + runbook manuale**, poi automate.
|
||||||
|
|
||||||
|
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se l’overflow è sufficiente o se serve anche più ingest/baseline hardware.
|
||||||
|
)
|
||||||
+1
-1
@@ -14,7 +14,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
|
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ RAILS_LOG_LEVEL=info
|
|||||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
@@ -150,6 +150,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
||||||
|
- ./nginx-edge/well-known:/usr/share/nginx/well-known:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ services:
|
|||||||
RAILS_LOG_TO_STDOUT: "true"
|
RAILS_LOG_TO_STDOUT: "true"
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
garage.prod.toml
|
||||||
|
prod-credentials.env
|
||||||
@@ -98,6 +98,13 @@ http {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Android App Links (Play Console / Digital Asset Links)
|
||||||
|
location = /.well-known/assetlinks.json {
|
||||||
|
default_type application/json;
|
||||||
|
add_header Cache-Control "public, max-age=300";
|
||||||
|
alias /usr/share/nginx/well-known/assetlinks.json;
|
||||||
|
}
|
||||||
|
|
||||||
# Health check ops: pass-through senza pagina di cortesia.
|
# Health check ops: pass-through senza pagina di cortesia.
|
||||||
location = /up {
|
location = /up {
|
||||||
proxy_pass http://$rails_backend;
|
proxy_pass http://$rails_backend;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*.iml
|
*.iml
|
||||||
/captures/
|
/captures/
|
||||||
/app/build/
|
/app/build/
|
||||||
|
/dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
keystore.properties
|
keystore.properties
|
||||||
keystore/
|
keystore/
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import java.io.File
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -20,13 +24,18 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 26
|
versionCode = 31
|
||||||
versionName = "2.0.5-native"
|
versionName = "2.0.10-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// Anche in defaultConfig: AGP estrae simboli se le .so non sono già stripped.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -47,6 +56,11 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Le .so AndroidX arrivano già stripped: AGP non genera metadata.
|
||||||
|
// Il task injectNativeDebugSymbolsInReleaseBundle le incorpora nell'AAB.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
signingConfigs.getByName("release")
|
signingConfigs.getByName("release")
|
||||||
} else {
|
} else {
|
||||||
@@ -108,3 +122,118 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test:core:1.6.1")
|
androidTestImplementation("androidx.test:core:1.6.1")
|
||||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play Console: le .so prebuilt (AndroidX) sono già stripped → AGP salta l'estrazione
|
||||||
|
// (niente BUNDLE-METADATA/com.android.tools.build.debugsymbols). Generiamo i .so.sym
|
||||||
|
// come fa AGP (llvm-objcopy --strip-debug) e li iniettiamo nell'intermediary AAB.
|
||||||
|
fun findLlvmObjcopy(): File {
|
||||||
|
val ndkRoot = File(android.sdkDirectory, "ndk")
|
||||||
|
val ndkDirs = ndkRoot.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }.orEmpty()
|
||||||
|
for (ndk in ndkDirs) {
|
||||||
|
val candidate = ndk.resolve("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy")
|
||||||
|
if (candidate.isFile) return candidate
|
||||||
|
}
|
||||||
|
error("llvm-objcopy non trovato sotto ${ndkRoot.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val injectNativeDebugSymbolsInReleaseBundle by tasks.registering {
|
||||||
|
description = "Embed native-debug-symbols.zip (.so.sym) into the release AAB intermediary"
|
||||||
|
val mergeNative = tasks.named("mergeReleaseNativeLibs")
|
||||||
|
val packageBundle = tasks.named("packageReleaseBundle")
|
||||||
|
dependsOn(mergeNative, packageBundle)
|
||||||
|
inputs.dir(
|
||||||
|
layout.buildDirectory.dir(
|
||||||
|
"intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doLast {
|
||||||
|
val libsDir = layout.buildDirectory
|
||||||
|
.dir("intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
val aabFile = layout.buildDirectory
|
||||||
|
.file("intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
require(libsDir.isDirectory) { "Native libs missing: $libsDir" }
|
||||||
|
require(aabFile.isFile) { "Intermediary AAB missing: $aabFile" }
|
||||||
|
|
||||||
|
val objcopy = findLlvmObjcopy()
|
||||||
|
val workDir = layout.buildDirectory.dir("tmp/native-debug-symbols-inject").get().asFile
|
||||||
|
workDir.deleteRecursively()
|
||||||
|
workDir.mkdirs()
|
||||||
|
val symbolsRoot = workDir.resolve("symbols")
|
||||||
|
symbolsRoot.mkdirs()
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
libsDir.walkTopDown().filter { it.isFile && it.extension == "so" }.forEach { so ->
|
||||||
|
val rel = so.relativeTo(libsDir).invariantSeparatorsPath
|
||||||
|
val outRel = "$rel.sym"
|
||||||
|
val outFile = symbolsRoot.resolve(outRel)
|
||||||
|
outFile.parentFile.mkdirs()
|
||||||
|
// Allineato ad AGP SYMBOL_TABLE: llvm-objcopy --strip-debug → *.so.sym
|
||||||
|
val proc = ProcessBuilder(
|
||||||
|
objcopy.absolutePath,
|
||||||
|
"--strip-debug",
|
||||||
|
so.absolutePath,
|
||||||
|
outFile.absolutePath,
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
val out = proc.inputStream.bufferedReader().readText()
|
||||||
|
check(proc.waitFor() == 0) {
|
||||||
|
"objcopy failed for $rel: $out"
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
check(count > 0) { "Nessuna .so da cui generare simboli in $libsDir" }
|
||||||
|
|
||||||
|
val symbolsZip = workDir.resolve("native-debug-symbols.zip")
|
||||||
|
ZipOutputStream(symbolsZip.outputStream().buffered()).use { zos ->
|
||||||
|
symbolsRoot.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||||
|
val rel = f.relativeTo(symbolsRoot).invariantSeparatorsPath
|
||||||
|
zos.putNextEntry(ZipEntry(rel))
|
||||||
|
f.inputStream().use { it.copyTo(zos) }
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val metaPath = "BUNDLE-METADATA/com.android.tools.build.debugsymbols/native-debug-symbols.zip"
|
||||||
|
val outAab = workDir.resolve("intermediary-with-symbols.aab")
|
||||||
|
ZipFile(aabFile).use { src ->
|
||||||
|
ZipOutputStream(outAab.outputStream().buffered()).use { dst ->
|
||||||
|
val entries = src.entries()
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
val entry = entries.nextElement()
|
||||||
|
if (entry.name == metaPath) continue
|
||||||
|
val newEntry = ZipEntry(entry.name)
|
||||||
|
newEntry.time = entry.time
|
||||||
|
// Deflate all entries when rewriting; STORED copy is fragile across ZipFile.
|
||||||
|
newEntry.method = ZipEntry.DEFLATED
|
||||||
|
dst.putNextEntry(newEntry)
|
||||||
|
src.getInputStream(entry).use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
dst.putNextEntry(ZipEntry(metaPath))
|
||||||
|
symbolsZip.inputStream().use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outAab.copyTo(aabFile, overwrite = true)
|
||||||
|
logger.lifecycle(
|
||||||
|
"Injected $metaPath ($count .so.sym, ${symbolsZip.length()} bytes) into ${aabFile.name} via ${objcopy.name}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "signReleaseBundle" }.configureEach {
|
||||||
|
dependsOn(injectNativeDebugSymbolsInReleaseBundle)
|
||||||
|
}
|
||||||
|
|||||||
+7
-6
@@ -40,12 +40,13 @@ class ThermalStateManager(
|
|||||||
fun start() {
|
fun start() {
|
||||||
monitor.onStateChanged = { next ->
|
monitor.onStateChanged = { next ->
|
||||||
val previous = _state.value
|
val previous = _state.value
|
||||||
if (next == previous) return@onStateChanged
|
if (next != previous) {
|
||||||
Log.i(TAG, "[Thermal] $previous → $next")
|
Log.i(TAG, "[Thermal] $previous → $next")
|
||||||
_state.value = next
|
_state.value = next
|
||||||
_noticeMessage.value = userNotice(next, previous)
|
_noticeMessage.value = userNotice(next, previous)
|
||||||
applyAdaptation(next, force = false)
|
applyAdaptation(next, force = false)
|
||||||
updateCriticalWatch(next)
|
updateCriticalWatch(next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
monitor.start()
|
monitor.start()
|
||||||
_state.value = monitor.currentState
|
_state.value = monitor.currentState
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.matchlivetv.match_live_tv.core.AppConfig
|
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.core.TokenStore
|
||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
|
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
|
||||||
@@ -37,10 +38,20 @@ class AppContainer(context: Context) {
|
|||||||
accessToken?.let { header("Authorization", "Bearer $it") }
|
accessToken?.let { header("Authorization", "Bearer $it") }
|
||||||
header("Accept", "application/json")
|
header("Accept", "application/json")
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
|
header("Accept-Language", apiLanguageTag())
|
||||||
}.build()
|
}.build()
|
||||||
chain.proceed(request)
|
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()
|
private val okHttp = OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ data class LoginRequest(val email: String, val password: String)
|
|||||||
|
|
||||||
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
||||||
|
|
||||||
|
data class ForgotPasswordRequest(val email: String)
|
||||||
|
|
||||||
|
data class ForgotPasswordResponse(val message: String)
|
||||||
|
|
||||||
|
data class UpdateAccountRequest(val name: String)
|
||||||
|
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
@Json(name = "current_password") val currentPassword: String,
|
||||||
|
val password: String,
|
||||||
|
@Json(name = "password_confirmation") val passwordConfirmation: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessageResponse(val message: String)
|
||||||
|
|
||||||
|
data class ApiErrorResponse(val error: String? = null)
|
||||||
|
|
||||||
data class LoginResponse(
|
data class LoginResponse(
|
||||||
val user: UserDto,
|
val user: UserDto,
|
||||||
@Json(name = "access_token") val accessToken: String,
|
@Json(name = "access_token") val accessToken: String,
|
||||||
|
|||||||
+12
@@ -23,6 +23,18 @@ interface MatchLiveApi {
|
|||||||
@POST("auth/logout")
|
@POST("auth/logout")
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
|
|
||||||
|
@POST("auth/password/forgot")
|
||||||
|
suspend fun forgotPassword(@Body body: ForgotPasswordRequest): ForgotPasswordResponse
|
||||||
|
|
||||||
|
@GET("account")
|
||||||
|
suspend fun account(): UserDto
|
||||||
|
|
||||||
|
@PATCH("account")
|
||||||
|
suspend fun updateAccount(@Body body: UpdateAccountRequest): UserDto
|
||||||
|
|
||||||
|
@PATCH("account/password")
|
||||||
|
suspend fun changePassword(@Body body: ChangePasswordRequest): MessageResponse
|
||||||
|
|
||||||
@GET("sports")
|
@GET("sports")
|
||||||
suspend fun sports(): List<SportDto>
|
suspend fun sports(): List<SportDto>
|
||||||
|
|
||||||
|
|||||||
+31
@@ -2,9 +2,13 @@ package com.matchlivetv.match_live_tv.data.repository
|
|||||||
|
|
||||||
import com.matchlivetv.match_live_tv.core.StoredSession
|
import com.matchlivetv.match_live_tv.core.StoredSession
|
||||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.ChangePasswordRequest
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.ForgotPasswordRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.UpdateAccountRequest
|
||||||
|
import com.matchlivetv.match_live_tv.domain.User
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
class AuthRepository(
|
class AuthRepository(
|
||||||
@@ -46,6 +50,33 @@ class AuthRepository(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun forgotPassword(email: String): String {
|
||||||
|
return api.forgotPassword(ForgotPasswordRequest(email.trim())).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAccount(): User = api.account().toDomain()
|
||||||
|
|
||||||
|
suspend fun updateName(name: String): User {
|
||||||
|
val user = api.updateAccount(UpdateAccountRequest(name.trim())).toDomain()
|
||||||
|
val stored = currentSession() ?: return user
|
||||||
|
tokenStore.saveSession(stored.copy(user = user))
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun changePassword(
|
||||||
|
currentPassword: String,
|
||||||
|
password: String,
|
||||||
|
passwordConfirmation: String,
|
||||||
|
) {
|
||||||
|
api.changePassword(
|
||||||
|
ChangePasswordRequest(
|
||||||
|
currentPassword = currentPassword,
|
||||||
|
password = password,
|
||||||
|
passwordConfirmation = passwordConfirmation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
runCatching { api.logout() }
|
runCatching { api.logout() }
|
||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,9 @@
|
|||||||
package com.matchlivetv.match_live_tv.streaming
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
|
||||||
|
* Audio: AAC 48 kHz mono 128 kbps — stesso contratto di `infra/scripts/generate_slate.sh`.
|
||||||
|
*/
|
||||||
data class BroadcastConfig(
|
data class BroadcastConfig(
|
||||||
val rtmpUrl: String,
|
val rtmpUrl: String,
|
||||||
val width: Int = 1280,
|
val width: Int = 1280,
|
||||||
@@ -11,7 +15,13 @@ data class BroadcastConfig(
|
|||||||
val portrait: Boolean = false,
|
val portrait: Boolean = false,
|
||||||
val maxReconnectAttempts: Int = 10,
|
val maxReconnectAttempts: Int = 10,
|
||||||
val reconnectDelayMs: Long = 5_000L,
|
val reconnectDelayMs: Long = 5_000L,
|
||||||
)
|
) {
|
||||||
|
companion object {
|
||||||
|
const val AUDIO_SAMPLE_RATE_HZ = 48_000
|
||||||
|
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
|
||||||
|
const val AUDIO_STEREO = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class BroadcastPhase {
|
enum class BroadcastPhase {
|
||||||
IDLE,
|
IDLE,
|
||||||
|
|||||||
+5
-1
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
|
|||||||
rotation = cfg.rotation,
|
rotation = cfg.rotation,
|
||||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||||
level = h264Level,
|
level = h264Level,
|
||||||
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
) && stream.prepareAudio(
|
||||||
|
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
|
||||||
|
BroadcastConfig.AUDIO_STEREO,
|
||||||
|
cfg.audioBitrate,
|
||||||
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "preparePipeline: ${it.message}")
|
Log.w(TAG, "preparePipeline: ${it.message}")
|
||||||
false
|
false
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.ui.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.matchlivetv.match_live_tv.R
|
||||||
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onLoggedOut: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var role by remember { mutableStateOf("") }
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var currentPassword by remember { mutableStateOf("") }
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var loadingProfile by remember { mutableStateOf(true) }
|
||||||
|
var savingProfile by remember { mutableStateOf(false) }
|
||||||
|
var savingPassword by remember { mutableStateOf(false) }
|
||||||
|
var loggingOut by remember { mutableStateOf(false) }
|
||||||
|
var profileMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var profileError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var passwordMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val errGeneric = stringResource(R.string.common_error_generic)
|
||||||
|
val profileSaved = stringResource(R.string.account_profile_saved)
|
||||||
|
val passwordSaved = stringResource(R.string.account_password_saved)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { container.authRepository.fetchAccount() }
|
||||||
|
.onSuccess { user ->
|
||||||
|
email = user.email
|
||||||
|
name = user.name
|
||||||
|
role = user.role
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
profileError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
loadingProfile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.account_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.common_close),
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MatchColors.Background,
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_profile_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
if (role.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_role, role),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.account_name_label)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !loadingProfile && !savingProfile,
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
profileError?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed)
|
||||||
|
}
|
||||||
|
profileMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.TextSecondary)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.account_save_profile),
|
||||||
|
loading = savingProfile,
|
||||||
|
enabled = !loadingProfile && name.isNotBlank() && !savingProfile,
|
||||||
|
onClick = {
|
||||||
|
savingProfile = true
|
||||||
|
profileError = null
|
||||||
|
profileMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching { container.authRepository.updateName(name) }
|
||||||
|
.onSuccess {
|
||||||
|
name = it.name
|
||||||
|
profileMessage = profileSaved
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
profileError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
savingProfile = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_password_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = currentPassword,
|
||||||
|
onValueChange = { currentPassword = it },
|
||||||
|
label = stringResource(R.string.account_current_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = { newPassword = it },
|
||||||
|
label = stringResource(R.string.account_new_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = stringResource(R.string.account_confirm_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
passwordError?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed)
|
||||||
|
}
|
||||||
|
passwordMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.TextSecondary)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.account_save_password),
|
||||||
|
loading = savingPassword,
|
||||||
|
enabled = currentPassword.isNotBlank() &&
|
||||||
|
newPassword.isNotBlank() &&
|
||||||
|
confirmPassword.isNotBlank() &&
|
||||||
|
!savingPassword,
|
||||||
|
onClick = {
|
||||||
|
savingPassword = true
|
||||||
|
passwordError = null
|
||||||
|
passwordMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
container.authRepository.changePassword(
|
||||||
|
currentPassword = currentPassword,
|
||||||
|
password = newPassword,
|
||||||
|
passwordConfirmation = confirmPassword,
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
currentPassword = ""
|
||||||
|
newPassword = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
passwordMessage = passwordSaved
|
||||||
|
}.onFailure {
|
||||||
|
passwordError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
savingPassword = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
MatchSecondaryButton(
|
||||||
|
label = stringResource(R.string.action_logout),
|
||||||
|
enabled = !loggingOut,
|
||||||
|
onClick = {
|
||||||
|
loggingOut = true
|
||||||
|
scope.launch {
|
||||||
|
container.authRepository.logout()
|
||||||
|
onLoggedOut()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
visible: Boolean,
|
||||||
|
onToggleVisible: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = onToggleVisible) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun accountFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
disabledTextColor = MatchColors.TextSecondary,
|
||||||
|
focusedBorderColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedBorderColor = MatchColors.Outline,
|
||||||
|
disabledBorderColor = MatchColors.Outline,
|
||||||
|
focusedLabelColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||||
|
disabledLabelColor = MatchColors.TextSecondary,
|
||||||
|
cursorColor = MatchColors.PrimaryRed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun apiErrorMessage(error: Throwable): String? {
|
||||||
|
if (error is HttpException) {
|
||||||
|
val body = error.response()?.errorBody()?.string().orEmpty()
|
||||||
|
val match = Regex("\"error\"\\s*:\\s*\"([^\"]+)\"").find(body)
|
||||||
|
if (match != null) return match.groupValues[1]
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.ui.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.matchlivetv.match_live_tv.R
|
||||||
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ForgotPasswordScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var success by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val errGeneric = stringResource(R.string.common_error_generic)
|
||||||
|
val successMessage = stringResource(R.string.forgot_password_success)
|
||||||
|
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.forgot_password_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.common_close),
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MatchColors.Background,
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.forgot_password_lead),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !success,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { }),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
focusedBorderColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedBorderColor = MatchColors.Outline,
|
||||||
|
focusedLabelColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||||
|
cursorColor = MatchColors.PrimaryRed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(successMessage, color = MatchColors.TextSecondary, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.forgot_password_submit),
|
||||||
|
loading = loading,
|
||||||
|
enabled = email.isNotBlank() && !loading && !success,
|
||||||
|
onClick = {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching { container.authRepository.forgotPassword(email) }
|
||||||
|
.onSuccess { success = true }
|
||||||
|
.onFailure { error = it.message ?: errGeneric }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -1,5 +1,6 @@
|
|||||||
package com.matchlivetv.match_live_tv.ui.login
|
package com.matchlivetv.match_live_tv.ui.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.matchlivetv.match_live_tv.R
|
import com.matchlivetv.match_live_tv.R
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
onLoggedIn: () -> Unit,
|
onLoggedIn: () -> Unit,
|
||||||
|
onForgotPassword: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var email by remember { mutableStateOf("") }
|
var email by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@@ -170,6 +173,16 @@ fun LoginScreen(
|
|||||||
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
||||||
colors = matchTextFieldColors(),
|
colors = matchTextFieldColors(),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_forgot_password),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.clickable(onClick = onForgotPassword)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
error?.let {
|
error?.let {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+5
-10
@@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
|
||||||
import androidx.compose.material.icons.filled.DeleteOutline
|
import androidx.compose.material.icons.filled.DeleteOutline
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -67,7 +67,7 @@ fun MatchesScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
onOpenSetup: (matchId: String) -> Unit,
|
onOpenSetup: (matchId: String) -> Unit,
|
||||||
onOpenBroadcast: (sessionId: String) -> Unit,
|
onOpenBroadcast: (sessionId: String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onOpenAccount: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -179,15 +179,10 @@ fun MatchesScreen(
|
|||||||
tint = MatchColors.TextSecondary,
|
tint = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = onOpenAccount) {
|
||||||
scope.launch {
|
|
||||||
container.authRepository.logout()
|
|
||||||
onLogout()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
imageVector = Icons.Filled.Person,
|
||||||
contentDescription = stringResource(R.string.action_logout),
|
contentDescription = stringResource(R.string.account_title),
|
||||||
tint = MatchColors.TextSecondary,
|
tint = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -7,7 +7,9 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.account.AccountScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
||||||
|
import com.matchlivetv.match_live_tv.ui.login.ForgotPasswordScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
||||||
@@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Login) {
|
composable(Routes.Login) {
|
||||||
LoginScreen(container) {
|
LoginScreen(
|
||||||
navController.navigate(Routes.Matches) {
|
container = container,
|
||||||
popUpTo(Routes.Login) { inclusive = true }
|
onLoggedIn = {
|
||||||
}
|
navController.navigate(Routes.Matches) {
|
||||||
}
|
popUpTo(Routes.Login) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onForgotPassword = {
|
||||||
|
navController.navigate(Routes.ForgotPassword)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.ForgotPassword) {
|
||||||
|
ForgotPasswordScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Matches) {
|
composable(Routes.Matches) {
|
||||||
MatchesScreen(
|
MatchesScreen(
|
||||||
@@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
onOpenBroadcast = { sessionId ->
|
onOpenBroadcast = { sessionId ->
|
||||||
navController.navigate(Routes.broadcast(sessionId))
|
navController.navigate(Routes.broadcast(sessionId))
|
||||||
},
|
},
|
||||||
onLogout = {
|
onOpenAccount = {
|
||||||
|
navController.navigate(Routes.Account)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.Account) {
|
||||||
|
AccountScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onLoggedOut = {
|
||||||
navController.navigate(Routes.Login) {
|
navController.navigate(Routes.Login) {
|
||||||
popUpTo(Routes.Matches) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -3,7 +3,9 @@ package com.matchlivetv.match_live_tv.ui.navigation
|
|||||||
object Routes {
|
object Routes {
|
||||||
const val Splash = "splash"
|
const val Splash = "splash"
|
||||||
const val Login = "login"
|
const val Login = "login"
|
||||||
|
const val ForgotPassword = "forgot_password"
|
||||||
const val Matches = "matches"
|
const val Matches = "matches"
|
||||||
|
const val Account = "account"
|
||||||
const val Setup = "setup/{matchId}/{step}"
|
const val Setup = "setup/{matchId}/{step}"
|
||||||
const val Broadcast = "broadcast/{sessionId}"
|
const val Broadcast = "broadcast/{sessionId}"
|
||||||
|
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Dies kann nicht rückgängig gemacht werden.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Löschen</string>
|
<string name="action_delete">Löschen</string>
|
||||||
<string name="wizard_color_hue">Farbton</string>
|
<string name="wizard_color_hue">Farbton</string>
|
||||||
|
<string name="login_forgot_password">Passwort vergessen?</string>
|
||||||
|
<string name="forgot_password_title">Passwort vergessen</string>
|
||||||
|
<string name="forgot_password_lead">Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.</string>
|
||||||
|
<string name="forgot_password_submit">Reset-Link senden</string>
|
||||||
|
<string name="forgot_password_success">Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.</string>
|
||||||
|
<string name="account_title">Konto</string>
|
||||||
|
<string name="account_profile_heading">Profil</string>
|
||||||
|
<string name="account_password_heading">Passwort ändern</string>
|
||||||
|
<string name="account_name_label">Name</string>
|
||||||
|
<string name="account_role">Rolle: %1$s</string>
|
||||||
|
<string name="account_current_password">Aktuelles Passwort</string>
|
||||||
|
<string name="account_new_password">Neues Passwort (mind. 8, mindestens 3 Zeichenarten)</string>
|
||||||
|
<string name="account_confirm_password">Passwort bestätigen</string>
|
||||||
|
<string name="account_save_profile">Profil speichern</string>
|
||||||
|
<string name="account_save_password">Passwort aktualisieren</string>
|
||||||
|
<string name="account_profile_saved">Profil aktualisiert</string>
|
||||||
|
<string name="account_password_saved">Passwort aktualisiert</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ This cannot be undone.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Delete</string>
|
<string name="action_delete">Delete</string>
|
||||||
<string name="wizard_color_hue">Hue</string>
|
<string name="wizard_color_hue">Hue</string>
|
||||||
|
<string name="login_forgot_password">Forgot password?</string>
|
||||||
|
<string name="forgot_password_title">Forgot password</string>
|
||||||
|
<string name="forgot_password_lead">Enter your account email: we will send you a link to reset your password.</string>
|
||||||
|
<string name="forgot_password_submit">Send reset link</string>
|
||||||
|
<string name="forgot_password_success">If the email is registered, you will receive a password reset link shortly.</string>
|
||||||
|
<string name="account_title">Account</string>
|
||||||
|
<string name="account_profile_heading">Profile</string>
|
||||||
|
<string name="account_password_heading">Change password</string>
|
||||||
|
<string name="account_name_label">Name</string>
|
||||||
|
<string name="account_role">Role: %1$s</string>
|
||||||
|
<string name="account_current_password">Current password</string>
|
||||||
|
<string name="account_new_password">New password (min. 8, at least 3 character types)</string>
|
||||||
|
<string name="account_confirm_password">Confirm password</string>
|
||||||
|
<string name="account_save_profile">Save profile</string>
|
||||||
|
<string name="account_save_password">Update password</string>
|
||||||
|
<string name="account_profile_saved">Profile updated</string>
|
||||||
|
<string name="account_password_saved">Password updated</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Esta acción no se puede deshacer.</string>
|
|||||||
<string name="login_email_placeholder">coach@equipo.com</string>
|
<string name="login_email_placeholder">coach@equipo.com</string>
|
||||||
<string name="action_delete">Eliminar</string>
|
<string name="action_delete">Eliminar</string>
|
||||||
<string name="wizard_color_hue">Tono</string>
|
<string name="wizard_color_hue">Tono</string>
|
||||||
|
<string name="login_forgot_password">¿Olvidaste la contraseña?</string>
|
||||||
|
<string name="forgot_password_title">Contraseña olvidada</string>
|
||||||
|
<string name="forgot_password_lead">Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.</string>
|
||||||
|
<string name="forgot_password_submit">Enviar enlace</string>
|
||||||
|
<string name="forgot_password_success">Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.</string>
|
||||||
|
<string name="account_title">Cuenta</string>
|
||||||
|
<string name="account_profile_heading">Perfil</string>
|
||||||
|
<string name="account_password_heading">Cambiar contraseña</string>
|
||||||
|
<string name="account_name_label">Nombre</string>
|
||||||
|
<string name="account_role">Rol: %1$s</string>
|
||||||
|
<string name="account_current_password">Contraseña actual</string>
|
||||||
|
<string name="account_new_password">Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)</string>
|
||||||
|
<string name="account_confirm_password">Confirmar contraseña</string>
|
||||||
|
<string name="account_save_profile">Guardar perfil</string>
|
||||||
|
<string name="account_save_password">Actualizar contraseña</string>
|
||||||
|
<string name="account_profile_saved">Perfil actualizado</string>
|
||||||
|
<string name="account_password_saved">Contraseña actualizada</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Cette action est irréversible.</string>
|
|||||||
<string name="login_email_placeholder">coach@equipe.com</string>
|
<string name="login_email_placeholder">coach@equipe.com</string>
|
||||||
<string name="action_delete">Supprimer</string>
|
<string name="action_delete">Supprimer</string>
|
||||||
<string name="wizard_color_hue">Teinte</string>
|
<string name="wizard_color_hue">Teinte</string>
|
||||||
|
<string name="login_forgot_password">Mot de passe oublié ?</string>
|
||||||
|
<string name="forgot_password_title">Mot de passe oublié</string>
|
||||||
|
<string name="forgot_password_lead">Saisissez l’e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.</string>
|
||||||
|
<string name="forgot_password_submit">Envoyer le lien</string>
|
||||||
|
<string name="forgot_password_success">Si l’e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.</string>
|
||||||
|
<string name="account_title">Compte</string>
|
||||||
|
<string name="account_profile_heading">Profil</string>
|
||||||
|
<string name="account_password_heading">Changer le mot de passe</string>
|
||||||
|
<string name="account_name_label">Nom</string>
|
||||||
|
<string name="account_role">Rôle : %1$s</string>
|
||||||
|
<string name="account_current_password">Mot de passe actuel</string>
|
||||||
|
<string name="account_new_password">Nouveau mot de passe (min. 8, au moins 3 types de caractères)</string>
|
||||||
|
<string name="account_confirm_password">Confirmer le mot de passe</string>
|
||||||
|
<string name="account_save_profile">Enregistrer le profil</string>
|
||||||
|
<string name="account_save_password">Mettre à jour le mot de passe</string>
|
||||||
|
<string name="account_profile_saved">Profil mis à jour</string>
|
||||||
|
<string name="account_password_saved">Mot de passe mis à jour</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ L\'operazione non si può annullare.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Elimina</string>
|
<string name="action_delete">Elimina</string>
|
||||||
<string name="wizard_color_hue">Tonalità</string>
|
<string name="wizard_color_hue">Tonalità</string>
|
||||||
|
<string name="login_forgot_password">Password dimenticata?</string>
|
||||||
|
<string name="forgot_password_title">Password dimenticata</string>
|
||||||
|
<string name="forgot_password_lead">Inserisci l’email dell’account: ti invieremo un link per reimpostare la password.</string>
|
||||||
|
<string name="forgot_password_submit">Invia link di reset</string>
|
||||||
|
<string name="forgot_password_success">Se l’email è registrata, riceverai a breve un link per reimpostare la password.</string>
|
||||||
|
<string name="account_title">Account</string>
|
||||||
|
<string name="account_profile_heading">Profilo</string>
|
||||||
|
<string name="account_password_heading">Cambia password</string>
|
||||||
|
<string name="account_name_label">Nome</string>
|
||||||
|
<string name="account_role">Ruolo: %1$s</string>
|
||||||
|
<string name="account_current_password">Password attuale</string>
|
||||||
|
<string name="account_new_password">Nuova password (min. 8, almeno 3 tipi di caratteri)</string>
|
||||||
|
<string name="account_confirm_password">Conferma password</string>
|
||||||
|
<string name="account_save_profile">Salva profilo</string>
|
||||||
|
<string name="account_save_password">Aggiorna password</string>
|
||||||
|
<string name="account_profile_saved">Profilo aggiornato</string>
|
||||||
|
<string name="account_password_saved">Password aggiornata</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||||
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||||
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||||
|
E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountScreen.swift; path = MatchLiveTv/UI/Account/AccountScreen.swift; sourceTree = "<group>"; };
|
||||||
|
DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ForgotPasswordScreen.swift; path = MatchLiveTv/UI/Login/ForgotPasswordScreen.swift; sourceTree = "<group>"; };
|
||||||
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||||
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||||
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||||
@@ -143,6 +145,8 @@
|
|||||||
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
||||||
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
||||||
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
||||||
|
A85ED43E903E4983B1438D11 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */; };
|
||||||
|
195AF301A0AC4BA58F8EDE95 /* ForgotPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */; };
|
||||||
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
||||||
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
||||||
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
||||||
@@ -176,10 +180,10 @@
|
|||||||
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
||||||
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
||||||
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
||||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, E13972C3F4664A89AE13D5D8, DDF38B8E56394DEBA022F812, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||||
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||||
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
||||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, A85ED43E903E4983B1438D11, 195AF301A0AC4BA58F8EDE95, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
|||||||
@@ -78,6 +78,23 @@ enum L10n {
|
|||||||
"action.exit": "Esci",
|
"action.exit": "Esci",
|
||||||
"action.login": "Accedi",
|
"action.login": "Accedi",
|
||||||
"action.logout": "Esci",
|
"action.logout": "Esci",
|
||||||
|
"account.confirm.password": "Conferma password",
|
||||||
|
"account.current.password": "Password attuale",
|
||||||
|
"account.name.label": "Nome",
|
||||||
|
"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",
|
||||||
|
"account.profile.saved": "Profilo aggiornato",
|
||||||
|
"account.role": "Ruolo: %1$@",
|
||||||
|
"account.save.password": "Aggiorna password",
|
||||||
|
"account.save.profile": "Salva profilo",
|
||||||
|
"account.title": "Account",
|
||||||
|
"forgot.password.lead": "Inserisci l'email dell'account: ti invieremo un link per reimpostare la password.",
|
||||||
|
"forgot.password.submit": "Invia link di reset",
|
||||||
|
"forgot.password.success": "Se l'email è registrata, riceverai a breve un link per reimpostare la password.",
|
||||||
|
"forgot.password.title": "Password dimenticata",
|
||||||
|
"login.forgot.password": "Password dimenticata?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Salva",
|
"action.save": "Salva",
|
||||||
"api.error.decoding": "Risposta del server non valida",
|
"api.error.decoding": "Risposta del server non valida",
|
||||||
@@ -352,6 +369,23 @@ enum L10n {
|
|||||||
"action.exit": "Exit",
|
"action.exit": "Exit",
|
||||||
"action.login": "Log in",
|
"action.login": "Log in",
|
||||||
"action.logout": "Log out",
|
"action.logout": "Log out",
|
||||||
|
"account.confirm.password": "Confirm password",
|
||||||
|
"account.current.password": "Current password",
|
||||||
|
"account.name.label": "Name",
|
||||||
|
"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",
|
||||||
|
"account.profile.saved": "Profile updated",
|
||||||
|
"account.role": "Role: %1$@",
|
||||||
|
"account.save.password": "Update password",
|
||||||
|
"account.save.profile": "Save profile",
|
||||||
|
"account.title": "Account",
|
||||||
|
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
|
||||||
|
"forgot.password.submit": "Send reset link",
|
||||||
|
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
||||||
|
"forgot.password.title": "Forgot password",
|
||||||
|
"login.forgot.password": "Forgot password?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Save",
|
"action.save": "Save",
|
||||||
"api.error.decoding": "Invalid server response",
|
"api.error.decoding": "Invalid server response",
|
||||||
@@ -626,6 +660,23 @@ enum L10n {
|
|||||||
"action.exit": "Quitter",
|
"action.exit": "Quitter",
|
||||||
"action.login": "Connexion",
|
"action.login": "Connexion",
|
||||||
"action.logout": "Déconnexion",
|
"action.logout": "Déconnexion",
|
||||||
|
"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, 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",
|
||||||
|
"account.profile.saved": "Profil mis à jour",
|
||||||
|
"account.role": "Rôle : %1$@",
|
||||||
|
"account.save.password": "Mettre à jour le mot de passe",
|
||||||
|
"account.save.profile": "Enregistrer le profil",
|
||||||
|
"account.title": "Compte",
|
||||||
|
"forgot.password.lead": "Saisissez l'e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.",
|
||||||
|
"forgot.password.submit": "Envoyer le lien",
|
||||||
|
"forgot.password.success": "Si l'e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.",
|
||||||
|
"forgot.password.title": "Mot de passe oublié",
|
||||||
|
"login.forgot.password": "Mot de passe oublié ?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Enregistrer",
|
"action.save": "Enregistrer",
|
||||||
"api.error.decoding": "Réponse du serveur non valide",
|
"api.error.decoding": "Réponse du serveur non valide",
|
||||||
@@ -900,6 +951,23 @@ enum L10n {
|
|||||||
"action.exit": "Beenden",
|
"action.exit": "Beenden",
|
||||||
"action.login": "Anmelden",
|
"action.login": "Anmelden",
|
||||||
"action.logout": "Abmelden",
|
"action.logout": "Abmelden",
|
||||||
|
"account.confirm.password": "Passwort bestätigen",
|
||||||
|
"account.current.password": "Aktuelles Passwort",
|
||||||
|
"account.name.label": "Name",
|
||||||
|
"account.new.password": "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)",
|
||||||
|
"account.password.heading": "Passwort ändern",
|
||||||
|
"account.password.saved": "Passwort aktualisiert",
|
||||||
|
"account.profile.heading": "Profil",
|
||||||
|
"account.profile.saved": "Profil aktualisiert",
|
||||||
|
"account.role": "Rolle: %1$@",
|
||||||
|
"account.save.password": "Passwort aktualisieren",
|
||||||
|
"account.save.profile": "Profil speichern",
|
||||||
|
"account.title": "Konto",
|
||||||
|
"forgot.password.lead": "Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.",
|
||||||
|
"forgot.password.submit": "Reset-Link senden",
|
||||||
|
"forgot.password.success": "Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.",
|
||||||
|
"forgot.password.title": "Passwort vergessen",
|
||||||
|
"login.forgot.password": "Passwort vergessen?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Speichern",
|
"action.save": "Speichern",
|
||||||
"api.error.decoding": "Ungültige Serverantwort",
|
"api.error.decoding": "Ungültige Serverantwort",
|
||||||
@@ -1174,6 +1242,23 @@ enum L10n {
|
|||||||
"action.exit": "Salir",
|
"action.exit": "Salir",
|
||||||
"action.login": "Acceder",
|
"action.login": "Acceder",
|
||||||
"action.logout": "Salir",
|
"action.logout": "Salir",
|
||||||
|
"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, al menos 3 tipos de caracteres)",
|
||||||
|
"account.password.heading": "Cambiar contraseña",
|
||||||
|
"account.password.saved": "Contraseña actualizada",
|
||||||
|
"account.profile.heading": "Perfil",
|
||||||
|
"account.profile.saved": "Perfil actualizado",
|
||||||
|
"account.role": "Rol: %1$@",
|
||||||
|
"account.save.password": "Actualizar contraseña",
|
||||||
|
"account.save.profile": "Guardar perfil",
|
||||||
|
"account.title": "Cuenta",
|
||||||
|
"forgot.password.lead": "Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.",
|
||||||
|
"forgot.password.submit": "Enviar enlace",
|
||||||
|
"forgot.password.success": "Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.",
|
||||||
|
"forgot.password.title": "Contraseña olvidada",
|
||||||
|
"login.forgot.password": "¿Olvidaste la contraseña?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Guardar",
|
"action.save": "Guardar",
|
||||||
"api.error.decoding": "Respuesta del servidor no válida",
|
"api.error.decoding": "Respuesta del servidor no válida",
|
||||||
|
|||||||
@@ -13,6 +13,34 @@ struct RefreshRequest: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ForgotPasswordRequest: Encodable {
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ForgotPasswordResponse: Decodable {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateAccountRequest: Encodable {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChangePasswordRequest: Encodable {
|
||||||
|
let currentPassword: String
|
||||||
|
let password: String
|
||||||
|
let passwordConfirmation: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case currentPassword = "current_password"
|
||||||
|
case password
|
||||||
|
case passwordConfirmation = "password_confirmation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageResponse: Decodable {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
struct LoginResponse: Decodable {
|
struct LoginResponse: Decodable {
|
||||||
let user: UserDto
|
let user: UserDto
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
|
|||||||
@@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
try await postVoid("auth/logout")
|
try await postVoid("auth/logout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forgotPassword(email: String) async throws -> ForgotPasswordResponse {
|
||||||
|
try await post("auth/password/forgot", body: ForgotPasswordRequest(email: email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func account() async throws -> UserDto {
|
||||||
|
try await get("account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAccount(name: String) async throws -> UserDto {
|
||||||
|
try await patch("account", body: UpdateAccountRequest(name: name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePassword(
|
||||||
|
currentPassword: String,
|
||||||
|
password: String,
|
||||||
|
passwordConfirmation: String
|
||||||
|
) async throws -> MessageResponse {
|
||||||
|
try await patch(
|
||||||
|
"account/password",
|
||||||
|
body: ChangePasswordRequest(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: password,
|
||||||
|
passwordConfirmation: passwordConfirmation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sports & Teams
|
// MARK: - Sports & Teams
|
||||||
|
|
||||||
func sports() async throws -> [SportDto] {
|
func sports() async throws -> [SportDto] {
|
||||||
@@ -213,6 +240,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
request.httpMethod = "PATCH"
|
request.httpMethod = "PATCH"
|
||||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
|
||||||
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
||||||
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
|
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
|
||||||
return try await execute(request)
|
return try await execute(request)
|
||||||
@@ -226,6 +254,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||||
request.httpMethod = method
|
request.httpMethod = method
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
|
||||||
if body != nil {
|
if body != nil {
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try ApiInstant.encoder.encode(body)
|
request.httpBody = try ApiInstant.encoder.encode(body)
|
||||||
@@ -267,6 +296,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
return path.hasSuffix("/auth/login")
|
return path.hasSuffix("/auth/login")
|
||||||
|| path.hasSuffix("/auth/refresh")
|
|| path.hasSuffix("/auth/refresh")
|
||||||
|| path.hasSuffix("/auth/register")
|
|| path.hasSuffix("/auth/register")
|
||||||
|
|| path.hasSuffix("/auth/password/forgot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,31 @@ final class AuthRepository {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forgotPassword(email: String) async throws -> String {
|
||||||
|
try await api.forgotPassword(email: email.trimmingCharacters(in: .whitespacesAndNewlines)).message
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAccount() async throws -> User {
|
||||||
|
try await api.account().toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateName(_ name: String) async throws -> User {
|
||||||
|
let user = try await api.updateAccount(name: name.trimmingCharacters(in: .whitespacesAndNewlines)).toDomain()
|
||||||
|
if var stored = tokenStore.session {
|
||||||
|
stored = StoredSession(user: user, accessToken: stored.accessToken, refreshToken: stored.refreshToken)
|
||||||
|
tokenStore.saveSession(stored)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePassword(currentPassword: String, password: String, passwordConfirmation: String) async throws {
|
||||||
|
_ = try await api.changePassword(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: password,
|
||||||
|
passwordConfirmation: passwordConfirmation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func logout() async {
|
func logout() async {
|
||||||
try? await api.logout()
|
try? await api.logout()
|
||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
|
||||||
|
/// Audio: AAC 48 kHz mono 128 kbps.
|
||||||
struct BroadcastConfig: Sendable {
|
struct BroadcastConfig: Sendable {
|
||||||
let rtmpUrl: String
|
let rtmpUrl: String
|
||||||
var width: Int = 1280
|
var width: Int = 1280
|
||||||
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
|
|||||||
var portrait: Bool = false
|
var portrait: Bool = false
|
||||||
var maxReconnectAttempts: Int = 10
|
var maxReconnectAttempts: Int = 10
|
||||||
var reconnectDelayMs: Int = 5_000
|
var reconnectDelayMs: Int = 5_000
|
||||||
|
|
||||||
|
static let audioSampleRateHz: Float64 = 48_000
|
||||||
|
static let audioChannels: UInt32 = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BroadcastPhase: String, Sendable {
|
enum BroadcastPhase: String, Sendable {
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
if let microphone = AVCaptureDevice.default(for: .audio) {
|
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||||
try await mixer.attachAudio(microphone, track: 0)
|
try await mixer.attachAudio(microphone, track: 0)
|
||||||
}
|
}
|
||||||
|
// AAC 48 kHz mono — allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
|
||||||
|
try await mixer.setAudioMixerSettings(
|
||||||
|
AudioMixerSettings(
|
||||||
|
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||||
|
channels: BroadcastConfig.audioChannels,
|
||||||
|
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
|
||||||
|
)
|
||||||
|
)
|
||||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||||
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
||||||
videoUnit.isVideoMirrored = false
|
videoUnit.isVideoMirrored = false
|
||||||
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
var audioSettings = await stream.audioSettings
|
var audioSettings = await stream.audioSettings
|
||||||
audioSettings = AudioCodecSettings(
|
audioSettings = AudioCodecSettings(
|
||||||
bitRate: config.audioBitrate,
|
bitRate: config.audioBitrate,
|
||||||
downmix: audioSettings.downmix,
|
downmix: true,
|
||||||
channelMap: audioSettings.channelMap,
|
channelMap: [0],
|
||||||
sampleRate: 48_000,
|
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||||
format: audioSettings.format
|
format: .aac
|
||||||
)
|
)
|
||||||
try await stream.setAudioSettings(audioSettings)
|
try await stream.setAudioSettings(audioSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onBack: () -> Void
|
||||||
|
let onLoggedOut: () -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var role = ""
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var currentPassword = ""
|
||||||
|
@State private var newPassword = ""
|
||||||
|
@State private var confirmPassword = ""
|
||||||
|
@State private var passwordVisible = false
|
||||||
|
@State private var loadingProfile = true
|
||||||
|
@State private var savingProfile = false
|
||||||
|
@State private var savingPassword = false
|
||||||
|
@State private var loggingOut = false
|
||||||
|
@State private var profileMessage: String?
|
||||||
|
@State private var profileError: String?
|
||||||
|
@State private var passwordMessage: String?
|
||||||
|
@State private var passwordError: String?
|
||||||
|
@State private var languageTick = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("account.title"))
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(L10n.t("account.profile.heading"))
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
disabledField(title: L10n.t("login.email"), value: email)
|
||||||
|
if !role.isEmpty {
|
||||||
|
Text(L10n.t("account.role", role))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
MatchAccountTextField(title: L10n.t("account.name.label"), text: $name)
|
||||||
|
|
||||||
|
if let profileError {
|
||||||
|
Text(profileError).foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
if let profileMessage {
|
||||||
|
Text(profileMessage).foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("account.save.profile"),
|
||||||
|
action: saveProfile,
|
||||||
|
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
||||||
|
loading: savingProfile
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(L10n.t("account.password.heading"))
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.current.password"),
|
||||||
|
text: $currentPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.new.password"),
|
||||||
|
text: $newPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.confirm.password"),
|
||||||
|
text: $confirmPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
|
||||||
|
if let passwordError {
|
||||||
|
Text(passwordError).foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
if let passwordMessage {
|
||||||
|
Text(passwordMessage).foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("account.save.password"),
|
||||||
|
action: savePassword,
|
||||||
|
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
||||||
|
loading: savingPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
MatchSecondaryButton(
|
||||||
|
label: L10n.t("action.logout"),
|
||||||
|
action: logout,
|
||||||
|
enabled: !loggingOut
|
||||||
|
)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.task { await loadAccount() }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||||
|
languageTick += 1
|
||||||
|
}
|
||||||
|
.id(languageTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disabledField(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
Text(value.isEmpty ? "—" : value)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAccount() async {
|
||||||
|
loadingProfile = true
|
||||||
|
do {
|
||||||
|
let user = try await container.authRepository.fetchAccount()
|
||||||
|
email = user.email
|
||||||
|
name = user.name
|
||||||
|
role = user.role
|
||||||
|
} catch {
|
||||||
|
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
loadingProfile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() {
|
||||||
|
savingProfile = true
|
||||||
|
profileError = nil
|
||||||
|
profileMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let user = try await container.authRepository.updateName(name)
|
||||||
|
name = user.name
|
||||||
|
profileMessage = L10n.t("account.profile.saved")
|
||||||
|
} catch {
|
||||||
|
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
savingProfile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePassword() {
|
||||||
|
savingPassword = true
|
||||||
|
passwordError = nil
|
||||||
|
passwordMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await container.authRepository.changePassword(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: newPassword,
|
||||||
|
passwordConfirmation: confirmPassword
|
||||||
|
)
|
||||||
|
currentPassword = ""
|
||||||
|
newPassword = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
passwordMessage = L10n.t("account.password.saved")
|
||||||
|
} catch {
|
||||||
|
passwordError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
savingPassword = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logout() {
|
||||||
|
loggingOut = true
|
||||||
|
Task {
|
||||||
|
await container.authRepository.logout()
|
||||||
|
onLoggedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchAccountTextField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField("", text: $text)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchAccountSecureField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var visible: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack {
|
||||||
|
Group {
|
||||||
|
if visible {
|
||||||
|
TextField("", text: $text)
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button(action: { visible.toggle() }) {
|
||||||
|
Image(systemName: visible ? "eye.slash" : "eye")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ForgotPasswordScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var loading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var success = false
|
||||||
|
@State private var languageTick = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("forgot.password.title"))
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(L10n.t("forgot.password.lead"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(L10n.t("login.email"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField("", text: $email)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.disabled(success)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
Text(L10n.t("forgot.password.success"))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("forgot.password.submit"),
|
||||||
|
action: submit,
|
||||||
|
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
||||||
|
loading: loading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||||
|
languageTick += 1
|
||||||
|
}
|
||||||
|
.id(languageTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
loading = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await container.authRepository.forgotPassword(email: email)
|
||||||
|
success = true
|
||||||
|
} catch {
|
||||||
|
self.error = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct LoginScreen: View {
|
struct LoginScreen: View {
|
||||||
@ObservedObject var container: AppContainer
|
@ObservedObject var container: AppContainer
|
||||||
let onLoggedIn: () -> Void
|
let onLoggedIn: () -> Void
|
||||||
|
let onForgotPassword: () -> Void
|
||||||
|
|
||||||
@State private var email = ""
|
@State private var email = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@@ -37,6 +38,16 @@ struct LoginScreen: View {
|
|||||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||||
}
|
}
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: onForgotPassword) {
|
||||||
|
Text(L10n.t("login.forgot.password"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
if let error {
|
if let error {
|
||||||
Text(error)
|
Text(error)
|
||||||
.foregroundStyle(MatchColors.primaryRed)
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct MatchesScreen: View {
|
|||||||
var refreshToken: Int = 0
|
var refreshToken: Int = 0
|
||||||
let onOpenSetup: (String) -> Void
|
let onOpenSetup: (String) -> Void
|
||||||
let onOpenBroadcast: (String) -> Void
|
let onOpenBroadcast: (String) -> Void
|
||||||
let onLogout: () -> Void
|
let onOpenAccount: () -> Void
|
||||||
|
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var refreshing = false
|
@State private var refreshing = false
|
||||||
@@ -37,15 +37,12 @@ struct MatchesScreen: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("language.label"))
|
.accessibilityLabel(L10n.t("language.label"))
|
||||||
Button {
|
Button {
|
||||||
Task {
|
onOpenAccount()
|
||||||
await container.authRepository.logout()
|
|
||||||
onLogout()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("action.logout"))
|
.accessibilityLabel(L10n.t("account.title"))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user