Merge branch 'feature/account-management'
Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
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: "Name is 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: "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 "Current password is incorrect"
|
||||||
|
when :too_short then "Password must be at least 8 characters"
|
||||||
|
when :mismatch then "Passwords do not match"
|
||||||
|
else "Unable to update password"
|
||||||
|
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(
|
||||||
@@ -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: "If the email is registered, you will receive a password reset link shortly."
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def token_response(user)
|
def token_response(user)
|
||||||
|
|||||||
@@ -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?
|
||||||
|
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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.blank? || @password.length < 8
|
||||||
|
return Result.new(ok?: false, error: :too_short)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @password != @password_confirmation
|
||||||
|
return Result.new(ok?: false, error: :mismatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
@user.update!(password: @password)
|
||||||
|
@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
|
||||||
@@ -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,55 @@
|
|||||||
|
<% 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>
|
||||||
|
<%= 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>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
<%= 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" %>
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ de:
|
|||||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||||
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_mismatch: Die Passwörter stimmen nicht überein
|
||||||
|
password_updated: Passwort aktualisiert.
|
||||||
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
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ en:
|
|||||||
password_min_length: Password must be at least 8 characters
|
password_min_length: Password must be at least 8 characters
|
||||||
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_mismatch: Passwords do not match
|
||||||
|
password_updated: Password updated.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download not available
|
download_unavailable: Download not available
|
||||||
not_available: Replay not available
|
not_available: Replay not available
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ es:
|
|||||||
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_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_mismatch: Las contraseñas no coinciden
|
||||||
|
password_updated: Contraseña actualizada.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Descarga no disponible
|
download_unavailable: Descarga no disponible
|
||||||
not_available: Repetición no disponible
|
not_available: Repetición no disponible
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ fr:
|
|||||||
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_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_mismatch: Les mots de passe ne correspondent pas
|
||||||
|
password_updated: Mot de passe mis à jour.
|
||||||
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
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ it:
|
|||||||
password_min_length: La password deve avere almeno 8 caratteri
|
password_min_length: La password deve avere almeno 8 caratteri
|
||||||
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_mismatch: Le password non coincidono
|
||||||
|
password_updated: Password aggiornata.
|
||||||
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
|
||||||
@@ -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 Zeichen)"
|
||||||
|
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
|
||||||
@@ -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 characters)"
|
||||||
|
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
|
||||||
@@ -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 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à
|
||||||
@@ -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 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
|
||||||
@@ -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 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,9 @@ 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"
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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()
|
||||||
|
|||||||
+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 Zeichen)</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 characters)</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 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 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 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 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 characters)",
|
||||||
|
"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 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 Zeichen)",
|
||||||
|
"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 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] {
|
||||||
@@ -267,6 +294,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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ struct AppNavHost: View {
|
|||||||
case .splash:
|
case .splash:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case .login:
|
case .login:
|
||||||
LoginScreen(container: container) { path = [.matches] }
|
LoginScreen(
|
||||||
|
container: container,
|
||||||
|
onLoggedIn: { path = [.matches] },
|
||||||
|
onForgotPassword: { path.append(.forgotPassword) }
|
||||||
|
)
|
||||||
|
case .forgotPassword:
|
||||||
|
ForgotPasswordScreen(
|
||||||
|
container: container,
|
||||||
|
onBack: { path.removeLast() }
|
||||||
|
)
|
||||||
case .matches:
|
case .matches:
|
||||||
MatchesScreen(
|
MatchesScreen(
|
||||||
container: container,
|
container: container,
|
||||||
@@ -38,9 +47,15 @@ struct AppNavHost: View {
|
|||||||
onOpenBroadcast: { sessionId in
|
onOpenBroadcast: { sessionId in
|
||||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||||
},
|
},
|
||||||
onLogout: { path = [.login] }
|
onOpenAccount: { path.append(.account) }
|
||||||
)
|
)
|
||||||
.lockPortraitOrientation()
|
.lockPortraitOrientation()
|
||||||
|
case .account:
|
||||||
|
AccountScreen(
|
||||||
|
container: container,
|
||||||
|
onBack: { path.removeLast() },
|
||||||
|
onLoggedOut: { path = [.login] }
|
||||||
|
)
|
||||||
case .setup, .broadcast:
|
case .setup, .broadcast:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import Foundation
|
|||||||
enum Routes: Hashable {
|
enum Routes: Hashable {
|
||||||
case splash
|
case splash
|
||||||
case login
|
case login
|
||||||
|
case forgotPassword
|
||||||
case matches
|
case matches
|
||||||
|
case account
|
||||||
case setup(matchId: String, step: Int)
|
case setup(matchId: String, step: Int)
|
||||||
case broadcast(sessionId: String)
|
case broadcast(sessionId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user