Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
@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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user