diff --git a/backend/app/controllers/api/v1/accounts_controller.rb b/backend/app/controllers/api/v1/accounts_controller.rb new file mode 100644 index 0000000..fc03a72 --- /dev/null +++ b/backend/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/auth_controller.rb b/backend/app/controllers/api/v1/auth_controller.rb index 378e8d2..f0b7439 100644 --- a/backend/app/controllers/api/v1/auth_controller.rb +++ b/backend/app/controllers/api/v1/auth_controller.rb @@ -1,7 +1,7 @@ module Api module V1 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 user = User.new( @@ -42,6 +42,13 @@ module Api render json: user_json(current_user) 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 def token_response(user) diff --git a/backend/app/controllers/public/accounts_controller.rb b/backend/app/controllers/public/accounts_controller.rb new file mode 100644 index 0000000..5677824 --- /dev/null +++ b/backend/app/controllers/public/accounts_controller.rb @@ -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 diff --git a/backend/app/controllers/public/password_resets_controller.rb b/backend/app/controllers/public/password_resets_controller.rb index 6ed84ac..5e3b9f1 100644 --- a/backend/app/controllers/public/password_resets_controller.rb +++ b/backend/app/controllers/public/password_resets_controller.rb @@ -4,11 +4,7 @@ module Public end def create - user = User.find_by(email: params[:email]&.downcase&.strip) - if user - token = user.generate_password_reset! - UserMailer.password_reset(user, token).deliver_now - end + Users::RequestPasswordReset.call(email: params[:email]) redirect_to public_login_path, notice: t("flash.password_resets.email_sent") diff --git a/backend/app/services/users/change_password.rb b/backend/app/services/users/change_password.rb new file mode 100644 index 0000000..5351bc0 --- /dev/null +++ b/backend/app/services/users/change_password.rb @@ -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 diff --git a/backend/app/services/users/request_password_reset.rb b/backend/app/services/users/request_password_reset.rb new file mode 100644 index 0000000..4c73b93 --- /dev/null +++ b/backend/app/services/users/request_password_reset.rb @@ -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 diff --git a/backend/app/views/layouts/public.html.erb b/backend/app/views/layouts/public.html.erb index 66ca0b9..31072a8 100644 --- a/backend/app/views/layouts/public.html.erb +++ b/backend/app/views/layouts/public.html.erb @@ -41,6 +41,7 @@ <% elsif current_user.manageable_teams.any? %> · <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %> <% 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" %> <% else %> · <%= link_to t("nav.login"), public_login_path %> diff --git a/backend/app/views/public/accounts/show.html.erb b/backend/app/views/public/accounts/show.html.erb new file mode 100644 index 0000000..e715c4c --- /dev/null +++ b/backend/app/views/public/accounts/show.html.erb @@ -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" %> + +
+

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

+ +
+

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

+ <%= form_with url: public_account_path, method: :patch, local: true do %> +

+
+ +

+

+ <%= t("auth.account.role_label", role: current_user.role) %> +

+ <%= 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 %> +
+ +
+

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

+ <%= 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 %> +
+
diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb index 0edcee8..1a34c10 100644 --- a/backend/app/views/shared/_marketing_nav.html.erb +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -34,6 +34,7 @@ <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %> <% 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" %> <% else %> <%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %> diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 1983571..68b778e 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -606,6 +606,13 @@ de: password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein password_mismatch: Die Passwörter stimmen nicht überein 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: download_unavailable: Download nicht verfügbar not_available: Replay nicht verfügbar diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index 831d0b1..b5c9355 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -606,6 +606,13 @@ en: password_min_length: Password must be at least 8 characters password_mismatch: Passwords don't match 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: download_unavailable: Download not available not_available: Replay not available diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index c21b77a..d1d984b 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -606,6 +606,13 @@ es: password_min_length: La contraseña debe tener al menos 8 caracteres password_mismatch: Las contraseñas no coinciden 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: download_unavailable: Descarga no disponible not_available: Repetición no disponible diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index 836b2fe..bd8a4f0 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -606,6 +606,13 @@ fr: password_min_length: Le mot de passe doit comporter au moins 8 caractères password_mismatch: Les mots de passe ne correspondent pas 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: download_unavailable: Téléchargement non disponible not_available: Replay non disponible diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index 91cfe9a..eee0e1d 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -606,6 +606,13 @@ it: password_min_length: La password deve avere almeno 8 caratteri password_mismatch: Le password non coincidono 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: download_unavailable: Download non disponibile not_available: Replay non disponibile diff --git a/backend/config/locales/ui.de.yml b/backend/config/locales/ui.de.yml index f591ade..9c3231a 100644 --- a/backend/config/locales/ui.de.yml +++ b/backend/config/locales/ui.de.yml @@ -16,6 +16,7 @@ de: my_team: Mein Team login: Anmelden logout: Abmelden + account: Konto signup: Team registrieren footer: tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können @@ -118,6 +119,18 @@ de: signup_link: registrieren Sie sich mobile_app_label: "Mobile App:" 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: privacy: Datenschutz cookies: Cookies diff --git a/backend/config/locales/ui.en.yml b/backend/config/locales/ui.en.yml index 0bb74b7..ea1e0ef 100644 --- a/backend/config/locales/ui.en.yml +++ b/backend/config/locales/ui.en.yml @@ -16,6 +16,7 @@ en: my_team: My team login: Log in logout: Log out + account: Account signup: Register a team footer: tagline: Every match, every event, for those who can't be there @@ -118,6 +119,18 @@ en: signup_link: sign up mobile_app_label: "Mobile 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: privacy: Privacy cookies: Cookies diff --git a/backend/config/locales/ui.es.yml b/backend/config/locales/ui.es.yml index 7f210e8..d230d9a 100644 --- a/backend/config/locales/ui.es.yml +++ b/backend/config/locales/ui.es.yml @@ -16,6 +16,7 @@ es: my_team: Mi equipo login: Acceder logout: Salir + account: Cuenta signup: Registrar equipo footer: tagline: Cada partido, cada evento, para quien no puede estar @@ -118,6 +119,18 @@ es: signup_link: regístrate mobile_app_label: "App móvil:" 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: privacy: Privacidad cookies: Cookies diff --git a/backend/config/locales/ui.fr.yml b/backend/config/locales/ui.fr.yml index 4c690a3..4711125 100644 --- a/backend/config/locales/ui.fr.yml +++ b/backend/config/locales/ui.fr.yml @@ -16,6 +16,7 @@ fr: my_team: Mon équipe login: Connexion logout: Déconnexion + account: Compte signup: Inscrire une équipe footer: tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là @@ -118,6 +119,18 @@ fr: signup_link: inscrivez-vous mobile_app_label: "Application mobile :" 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: privacy: Confidentialité cookies: Cookies diff --git a/backend/config/locales/ui.it.yml b/backend/config/locales/ui.it.yml index deccb26..91e5a68 100644 --- a/backend/config/locales/ui.it.yml +++ b/backend/config/locales/ui.it.yml @@ -16,6 +16,7 @@ it: my_team: La mia squadra login: Accedi logout: Esci + account: Account signup: Registra squadra footer: tagline: Ogni partita, ogni evento, per chi non può esserci @@ -118,6 +119,18 @@ it: signup_link: registrati mobile_app_label: "App mobile:" 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: privacy: Privacy cookies: Cookie diff --git a/backend/config/routes.rb b/backend/config/routes.rb index a2c7539..dd5d8cf 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -11,6 +11,10 @@ Rails.application.routes.draw do post "auth/logout", to: "auth#logout" post "auth/refresh", to: "auth#refresh" 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 "invitations/:token", to: "invitations#show" @@ -163,6 +167,9 @@ Rails.application.routes.draw do post "password/forgot", to: "password_resets#create" get "password/reset", to: "password_resets#edit", as: :password_reset 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 post "clubs", to: "clubs#create" get "clubs/:id", to: "clubs#show", as: :club diff --git a/backend/spec/requests/account_spec.rb b/backend/spec/requests/account_spec.rb new file mode 100644 index 0000000..a008fdb --- /dev/null +++ b/backend/spec/requests/account_spec.rb @@ -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 diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index d6635d3..aaa2863 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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 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( val user: UserDto, @Json(name = "access_token") val accessToken: String, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index 8832028..b620567 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -23,6 +23,18 @@ interface MatchLiveApi { @POST("auth/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") suspend fun sports(): List diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt index 50dc413..cfa62b3 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt @@ -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.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.MatchLiveApi 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 class AuthRepository( @@ -46,6 +50,33 @@ class AuthRepository( 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() { runCatching { api.logout() } tokenStore.clear() diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/account/AccountScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/account/AccountScreen.kt new file mode 100644 index 0000000..010c870 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/account/AccountScreen.kt @@ -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(null) } + var profileError by remember { mutableStateOf(null) } + var passwordMessage by remember { mutableStateOf(null) } + var passwordError by remember { mutableStateOf(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 +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/ForgotPasswordScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/ForgotPasswordScreen.kt new file mode 100644 index 0000000..f6935e1 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/ForgotPasswordScreen.kt @@ -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(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 + } + }, + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt index 571c617..ccab3bd 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt @@ -1,5 +1,6 @@ package com.matchlivetv.match_live_tv.ui.login +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.data.AppContainer @@ -53,6 +55,7 @@ import kotlinx.coroutines.launch fun LoginScreen( container: AppContainer, onLoggedIn: () -> Unit, + onForgotPassword: () -> Unit, ) { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -170,6 +173,16 @@ fun LoginScreen( keyboardActions = KeyboardActions(onDone = { submitLogin() }), 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 { Spacer(Modifier.height(12.dp)) Text( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt index 6196f22..4565d59 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape 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.Language +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -67,7 +67,7 @@ fun MatchesScreen( container: AppContainer, onOpenSetup: (matchId: String) -> Unit, onOpenBroadcast: (sessionId: String) -> Unit, - onLogout: () -> Unit, + onOpenAccount: () -> Unit, ) { val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -179,15 +179,10 @@ fun MatchesScreen( tint = MatchColors.TextSecondary, ) } - IconButton(onClick = { - scope.launch { - container.authRepository.logout() - onLogout() - } - }) { + IconButton(onClick = onOpenAccount) { Icon( - imageVector = Icons.AutoMirrored.Filled.Logout, - contentDescription = stringResource(R.string.action_logout), + imageVector = Icons.Filled.Person, + contentDescription = stringResource(R.string.account_title), tint = MatchColors.TextSecondary, ) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt index 4d387b7..5a75784 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt @@ -7,7 +7,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument 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.login.ForgotPasswordScreen 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.splash.SplashScreen @@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) { ) } composable(Routes.Login) { - LoginScreen(container) { - navController.navigate(Routes.Matches) { - popUpTo(Routes.Login) { inclusive = true } - } - } + LoginScreen( + container = container, + 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) { MatchesScreen( @@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) { onOpenBroadcast = { 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) { - popUpTo(Routes.Matches) { inclusive = true } + popUpTo(0) { inclusive = true } } }, ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt index e18ae6c..3edab3c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt @@ -3,7 +3,9 @@ package com.matchlivetv.match_live_tv.ui.navigation object Routes { const val Splash = "splash" const val Login = "login" + const val ForgotPassword = "forgot_password" const val Matches = "matches" + const val Account = "account" const val Setup = "setup/{matchId}/{step}" const val Broadcast = "broadcast/{sessionId}" diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml index 7a696b5..444ac2c 100644 --- a/native/android/app/src/main/res/values-de/strings.xml +++ b/native/android/app/src/main/res/values-de/strings.xml @@ -241,4 +241,21 @@ Dies kann nicht rückgängig gemacht werden. coach@team.com Löschen Farbton +Passwort vergessen? + Passwort vergessen + Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts. + Reset-Link senden + Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen. + Konto + Profil + Passwort ändern + Name + Rolle: %1$s + Aktuelles Passwort + Neues Passwort (mind. 8 Zeichen) + Passwort bestätigen + Profil speichern + Passwort aktualisieren + Profil aktualisiert + Passwort aktualisiert diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml index f5ed475..d6148fb 100644 --- a/native/android/app/src/main/res/values-en/strings.xml +++ b/native/android/app/src/main/res/values-en/strings.xml @@ -241,4 +241,21 @@ This cannot be undone. coach@team.com Delete Hue +Forgot password? + Forgot password + Enter your account email: we will send you a link to reset your password. + Send reset link + If the email is registered, you will receive a password reset link shortly. + Account + Profile + Change password + Name + Role: %1$s + Current password + New password (min. 8 characters) + Confirm password + Save profile + Update password + Profile updated + Password updated diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml index ec5dd36..38b39e8 100644 --- a/native/android/app/src/main/res/values-es/strings.xml +++ b/native/android/app/src/main/res/values-es/strings.xml @@ -241,4 +241,21 @@ Esta acción no se puede deshacer. coach@equipo.com Eliminar Tono +¿Olvidaste la contraseña? + Contraseña olvidada + Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña. + Enviar enlace + Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña. + Cuenta + Perfil + Cambiar contraseña + Nombre + Rol: %1$s + Contraseña actual + Nueva contraseña (mín. 8 caracteres) + Confirmar contraseña + Guardar perfil + Actualizar contraseña + Perfil actualizado + Contraseña actualizada diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml index 57451fa..bdd5c90 100644 --- a/native/android/app/src/main/res/values-fr/strings.xml +++ b/native/android/app/src/main/res/values-fr/strings.xml @@ -241,4 +241,21 @@ Cette action est irréversible. coach@equipe.com Supprimer Teinte +Mot de passe oublié ? + Mot de passe oublié + Saisissez l’e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe. + Envoyer le lien + Si l’e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation. + Compte + Profil + Changer le mot de passe + Nom + Rôle : %1$s + Mot de passe actuel + Nouveau mot de passe (min. 8 caractères) + Confirmer le mot de passe + Enregistrer le profil + Mettre à jour le mot de passe + Profil mis à jour + Mot de passe mis à jour diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml index bc37a25..508eb18 100644 --- a/native/android/app/src/main/res/values/strings.xml +++ b/native/android/app/src/main/res/values/strings.xml @@ -241,4 +241,21 @@ L\'operazione non si può annullare. coach@team.com Elimina Tonalità +Password dimenticata? + Password dimenticata + Inserisci l’email dell’account: ti invieremo un link per reimpostare la password. + Invia link di reset + Se l’email è registrata, riceverai a breve un link per reimpostare la password. + Account + Profilo + Cambia password + Nome + Ruolo: %1$s + Password attuale + Nuova password (min. 8 caratteri) + Conferma password + Salva profilo + Aggiorna password + Profilo aggiornato + Password aggiornata diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index f15d022..b67283e 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = ""; }; 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = ""; }; 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = ""; }; + E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountScreen.swift; path = MatchLiveTv/UI/Account/AccountScreen.swift; sourceTree = ""; }; + DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ForgotPasswordScreen.swift; path = MatchLiveTv/UI/Login/ForgotPasswordScreen.swift; sourceTree = ""; }; DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = ""; }; 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = ""; }; 5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = ""; }; @@ -143,6 +145,8 @@ D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; }; 52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.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 */; }; 7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.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; }; 5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; }; B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = ""; }; - 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 = ""; }; + 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 = ""; }; E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = ""; }; 2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = ""; }; - 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; }; 6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; }; AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift index 100b0dc..f2782f3 100644 --- a/native/ios/MatchLiveTv/Core/AppLanguage.swift +++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift @@ -78,6 +78,23 @@ enum L10n { "action.exit": "Esci", "action.login": "Accedi", "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.save": "Salva", "api.error.decoding": "Risposta del server non valida", @@ -352,6 +369,23 @@ enum L10n { "action.exit": "Exit", "action.login": "Log in", "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.save": "Save", "api.error.decoding": "Invalid server response", @@ -626,6 +660,23 @@ enum L10n { "action.exit": "Quitter", "action.login": "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.save": "Enregistrer", "api.error.decoding": "Réponse du serveur non valide", @@ -900,6 +951,23 @@ enum L10n { "action.exit": "Beenden", "action.login": "Anmelden", "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.save": "Speichern", "api.error.decoding": "Ungültige Serverantwort", @@ -1174,6 +1242,23 @@ enum L10n { "action.exit": "Salir", "action.login": "Acceder", "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.save": "Guardar", "api.error.decoding": "Respuesta del servidor no válida", diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift index f6632b4..26d5c77 100644 --- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -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 { let user: UserDto let accessToken: String diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift index 56052ea..d76b3ba 100644 --- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable { 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 func sports() async throws -> [SportDto] { @@ -267,6 +294,7 @@ final class MatchLiveAPI: @unchecked Sendable { return path.hasSuffix("/auth/login") || path.hasSuffix("/auth/refresh") || path.hasSuffix("/auth/register") + || path.hasSuffix("/auth/password/forgot") } } diff --git a/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift b/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift index dbee96c..7f0412b 100644 --- a/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift +++ b/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift @@ -47,6 +47,31 @@ final class AuthRepository { 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 { try? await api.logout() tokenStore.clear() diff --git a/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift b/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift new file mode 100644 index 0000000..ac5e8cc --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift @@ -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)) + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift b/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift new file mode 100644 index 0000000..56f558d --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift @@ -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 + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift index 369c223..5521f56 100644 --- a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift +++ b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift @@ -3,6 +3,7 @@ import SwiftUI struct LoginScreen: View { @ObservedObject var container: AppContainer let onLoggedIn: () -> Void + let onForgotPassword: () -> Void @State private var email = "" @State private var password = "" @@ -37,6 +38,16 @@ struct LoginScreen: View { MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible) } .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 { Text(error) .foregroundStyle(MatchColors.primaryRed) diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift index 35214c2..ae9916e 100644 --- a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift +++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift @@ -5,7 +5,7 @@ struct MatchesScreen: View { var refreshToken: Int = 0 let onOpenSetup: (String) -> Void let onOpenBroadcast: (String) -> Void - let onLogout: () -> Void + let onOpenAccount: () -> Void @State private var loading = true @State private var refreshing = false @@ -37,15 +37,12 @@ struct MatchesScreen: View { } .accessibilityLabel(L10n.t("language.label")) Button { - Task { - await container.authRepository.logout() - onLogout() - } + onOpenAccount() } label: { - Image(systemName: "rectangle.portrait.and.arrow.right") + Image(systemName: "person.crop.circle") .foregroundStyle(MatchColors.textSecondary) } - .accessibilityLabel(L10n.t("action.logout")) + .accessibilityLabel(L10n.t("account.title")) } .padding(.horizontal, 16) .padding(.vertical, 8) diff --git a/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift index 15316eb..05c8bdf 100644 --- a/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift +++ b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift @@ -27,7 +27,16 @@ struct AppNavHost: View { case .splash: EmptyView() 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: MatchesScreen( container: container, @@ -38,9 +47,15 @@ struct AppNavHost: View { onOpenBroadcast: { sessionId in broadcastRoute = BroadcastRoute(sessionId: sessionId) }, - onLogout: { path = [.login] } + onOpenAccount: { path.append(.account) } ) .lockPortraitOrientation() + case .account: + AccountScreen( + container: container, + onBack: { path.removeLast() }, + onLoggedOut: { path = [.login] } + ) case .setup, .broadcast: EmptyView() } diff --git a/native/ios/MatchLiveTv/UI/Navigation/Routes.swift b/native/ios/MatchLiveTv/UI/Navigation/Routes.swift index be805a7..b99d1a8 100644 --- a/native/ios/MatchLiveTv/UI/Navigation/Routes.swift +++ b/native/ios/MatchLiveTv/UI/Navigation/Routes.swift @@ -3,7 +3,9 @@ import Foundation enum Routes: Hashable { case splash case login + case forgotPassword case matches + case account case setup(matchId: String, step: Int) case broadcast(sessionId: String) }