Implement error messages for login & registration

This commit is contained in:
William Brawner 2022-06-07 21:14:20 -06:00
parent d72be0abd9
commit 5e1744f7d6
7 changed files with 174 additions and 79 deletions

View file

@ -30,7 +30,7 @@ class DataStore : ObservableObject {
} }
} }
@Published var overview: AsyncData<BudgetOverview> = .empty @Published var overview: AsyncData<BudgetOverview> = .empty
@Published var showBudgetSelection: Bool = true @Published var showBudgetSelection: Bool = false
@Published var editingBudget: Bool = false @Published var editingBudget: Bool = false
@Published var editingCategory: Bool = false @Published var editingCategory: Bool = false
@Published var editingRecurringTransaction: Bool = false @Published var editingRecurringTransaction: Bool = false
@ -81,6 +81,9 @@ class DataStore : ObservableObject {
do { do {
let budgets = try await self.apiService.getBudgets(count: count, page: page).sorted(by: { $0.name < $1.name }) let budgets = try await self.apiService.getBudgets(count: count, page: page).sorted(by: { $0.name < $1.name })
self.budgets = .success(budgets) self.budgets = .success(budgets)
if budgets.isEmpty {
showBudgetSelection = true
}
if self.budget != .empty { if self.budget != .empty {
return return
} }
@ -93,6 +96,7 @@ class DataStore : ObservableObject {
} }
} catch { } catch {
self.budgets = .error(error) self.budgets = .error(error)
showBudgetSelection = true
} }
} }
@ -479,6 +483,8 @@ class DataStore : ObservableObject {
switch currentUser { switch currentUser {
case .empty, .loading: case .empty, .loading:
self.showLogin = true self.showLogin = true
case .error(_, let user):
self.showLogin = user == nil
default: default:
self.showLogin = false self.showLogin = false
} }
@ -508,7 +514,15 @@ class DataStore : ObservableObject {
} }
@Published var showLogin: Bool = true @Published var showLogin: Bool = true
func clearUserError() {
self.currentUser = .empty
}
func login(username: String, password: String) async { func login(username: String, password: String) async {
if baseUrl.isEmpty {
self.currentUser = .error(NetworkError.invalidUrl)
return
}
self.currentUser = .loading self.currentUser = .loading
do { do {
let response = try await self.apiService.login(username: username, password: password) let response = try await self.apiService.login(username: username, password: password)
@ -527,9 +541,24 @@ class DataStore : ObservableObject {
} }
func register(username: String, email: String, password: String, confirmPassword: String) async { func register(username: String, email: String, password: String, confirmPassword: String) async {
// TODO: Validate other fields as well if baseUrl.isEmpty {
self.currentUser = .error(NetworkError.invalidUrl)
return
}
if username.isEmpty {
self.currentUser = .error(UsernameError.empty)
return
}
if !email.isEmpty && (!email.contains("@") || !email.contains(".")) {
self.currentUser = .error(EmailError.invalid)
return
}
if password.isEmpty {
self.currentUser = .error(PasswordError.empty)
return
}
if !password.elementsEqual(confirmPassword) { if !password.elementsEqual(confirmPassword) {
// TODO: Show error message to user self.currentUser = .error(PasswordError.notMatching)
return return
} }
do { do {
@ -541,6 +570,7 @@ class DataStore : ObservableObject {
default: default:
print(error.localizedDescription) print(error.localizedDescription)
} }
self.currentUser = .error(error)
return return
} }
await self.login(username: username, password: password) await self.login(username: username, password: password)
@ -568,7 +598,6 @@ class DataStore : ObservableObject {
func loadProfile() async { func loadProfile() async {
guard let userId = self.userId, !userId.isEmpty else { guard let userId = self.userId, !userId.isEmpty else {
self.currentUser = .error(UserStatus.unauthenticated)
return return
} }
do { do {
@ -659,7 +688,7 @@ class DataStore : ObservableObject {
} }
enum UsernameError: String, Error { enum UsernameError: String, Error {
case empty = "cannot_be_empty" case empty = "username_cannot_be_empty"
case unavailable = "username_taken" case unavailable = "username_taken"
case unknown = "unknown_error" case unknown = "unknown_error"
} }
@ -671,15 +700,7 @@ enum EmailError: String, Error {
} }
enum PasswordError: String, Error { enum PasswordError: String, Error {
case empty = "cannot_be_empty" case empty = "password_cannot_be_empty"
case notMatching = "passwords_dont_match" case notMatching = "passwords_dont_match"
case unknown = "unknown_error" case unknown = "unknown_error"
} }
enum UserStatus: Error, Equatable {
case unauthenticated
case authenticating
case failedAuthentication
case authenticated
case passwordMismatch
}

View file

@ -8,28 +8,46 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TwigsCore
struct LoginView: View { struct LoginView: View {
@State var username: String = "" @State var username: String = ""
@State var password: String = "" @State var password: String = ""
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
var loading: Bool { var error: String? {
switch dataStore.user { if case let .error(error as NetworkError, _) = dataStore.currentUser {
case .loading: switch error {
return true case .badRequest(let reason):
default: if reason == nil || reason?.isEmpty == true {
return false return "unknown_error"
}
return reason
case .server:
return "server_error"
case .invalidUrl, .notFound:
return "server_invalid"
case .unauthorized:
return "credentials_invalid"
default:
return "unknown_error"
}
} }
return nil
} }
var body: some View { var body: some View {
LoadingView( switch dataStore.currentUser {
isShowing: .constant(loading), case .loading:
loadingText: "loading_login" ActivityIndicator(isAnimating: .constant(true), style: .medium)
) { default:
NavigationView { NavigationView {
VStack { VStack {
Text("info_login") Text("info_login")
if let error = self.error {
Text(LocalizedStringKey(error))
.multilineTextAlignment(.center)
.foregroundColor(.red)
}
TextField(LocalizedStringKey("prompt_server"), text: self.$dataStore.baseUrl) TextField(LocalizedStringKey("prompt_server"), text: self.$dataStore.baseUrl)
.autocapitalization(.none) .autocapitalization(.none)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
@ -49,19 +67,35 @@ struct LoginView: View {
await self.dataStore.login(username: self.username, password: self.password) await self.dataStore.login(username: self.username, password: self.password)
} }
} }
Button("action_login", action: { Button(action: {
Task { Task {
await self.dataStore.login(username: self.username, password: self.password) await self.dataStore.login(username: self.username, password: self.password)
} }
}).buttonStyle(DefaultButtonStyle()) }, label: {
Text("action_login")
.frame(maxWidth: .infinity)
})
.buttonStyle(.borderedProminent)
Spacer() Spacer()
Text("info_register") HStack {
NavigationLink(destination: RegistrationView(username: self.$username, password: self.$password)) { Text("info_register")
Text("action_register") NavigationLink(
.buttonStyle(DefaultButtonStyle()) destination: RegistrationView(username: self.$username, password: self.$password)
.navigationTitle("action_register")
.onAppear {
dataStore.clearUserError()
}
.onDisappear {
dataStore.clearUserError()
}
) {
Text("action_register")
.buttonStyle(DefaultButtonStyle())
}
} }
}.padding() }.padding()
}.navigationBarHidden(true) }.navigationBarHidden(true)
.navigationTitle("action_login")
} }
} }
} }

View file

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct RegistrationView: View { struct RegistrationView: View {
@Binding var username: String @Binding var username: String
@ -14,27 +15,75 @@ struct RegistrationView: View {
@Binding var password: String @Binding var password: String
@State var confirmedPassword: String = "" @State var confirmedPassword: String = ""
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
var error: String? {
if case let .error(error as NetworkError, _) = dataStore.currentUser {
switch error {
case .badRequest(let reason):
if reason == nil || reason?.isEmpty == true {
return "unknown_error"
}
return reason
case .server:
return "server_error"
case .invalidUrl, .notFound:
return "server_invalid"
case .unauthorized:
return "credentials_invalid"
default:
return "unknown_error"
}
}
if case let .error(error as UsernameError, _) = dataStore.currentUser {
return error.rawValue
}
if case let .error(error as EmailError, _) = dataStore.currentUser {
return error.rawValue
}
if case let .error(error as PasswordError, _) = dataStore.currentUser {
return error.rawValue
}
return nil
}
var body: some View { var body: some View {
VStack { switch dataStore.currentUser {
TextField(LocalizedStringKey("prompt_server"), text: self.$dataStore.baseUrl) case .loading:
.textFieldStyle(RoundedBorderTextFieldStyle()) ActivityIndicator(isAnimating: .constant(true), style: .medium)
.textContentType(.URL) default:
TextField("prompt_username", text: self.$username) VStack {
.autocapitalization(UITextAutocapitalizationType.none) if let error = self.error {
.textFieldStyle(RoundedBorderTextFieldStyle()) Text(LocalizedStringKey(error))
.textContentType(UITextContentType.username) .multilineTextAlignment(.center)
TextField("prompt_email", text: self.$email) .foregroundColor(.red)
.textContentType(UITextContentType.emailAddress) }
.autocapitalization(UITextAutocapitalizationType.none) TextField(LocalizedStringKey("prompt_server"), text: self.$dataStore.baseUrl)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
SecureField("prompt_password", text: self.$password) .textContentType(.URL)
.textFieldStyle(RoundedBorderTextFieldStyle()) TextField("prompt_username", text: self.$username)
.textContentType(UITextContentType.newPassword) .autocapitalization(UITextAutocapitalizationType.none)
SecureField("prompt_confirm_password", text: self.$confirmedPassword) .textFieldStyle(RoundedBorderTextFieldStyle())
.textFieldStyle(RoundedBorderTextFieldStyle()) .textContentType(UITextContentType.username)
.textContentType(UITextContentType.newPassword) TextField("prompt_email", text: self.$email)
.onSubmit { .textContentType(UITextContentType.emailAddress)
.autocapitalization(UITextAutocapitalizationType.none)
.textFieldStyle(RoundedBorderTextFieldStyle())
SecureField("prompt_password", text: self.$password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.textContentType(UITextContentType.newPassword)
SecureField("prompt_confirm_password", text: self.$confirmedPassword)
.textFieldStyle(RoundedBorderTextFieldStyle())
.textContentType(UITextContentType.newPassword)
.onSubmit {
Task {
await self.dataStore.register(
username: self.username,
email: self.email,
password: self.password,
confirmPassword: self.confirmedPassword
)
}
}
Button(action: {
Task { Task {
await self.dataStore.register( await self.dataStore.register(
username: self.username, username: self.username,
@ -43,18 +92,13 @@ struct RegistrationView: View {
confirmPassword: self.confirmedPassword confirmPassword: self.confirmedPassword
) )
} }
} }, label: {
Button("action_register", action: { Text("action_register")
Task { .frame(maxWidth: .infinity)
await self.dataStore.register( })
username: self.username, .buttonStyle(.borderedProminent)
email: self.email, }.padding()
password: self.password, }
confirmPassword: self.confirmedPassword
)
}
}).buttonStyle(DefaultButtonStyle())
}.padding()
} }
} }

View file

@ -63,19 +63,9 @@ struct SidebarBudgetView: View {
var body: some View { var body: some View {
mainView mainView
.sheet(isPresented: $dataStore.showLogin, .sheet(isPresented: $dataStore.showLogin,
onDismiss: {
Task {
await self.dataStore.getBudgets()
}
},
content: { content: {
LoginView() LoginView()
.environmentObject(dataStore) .environmentObject(dataStore)
.onDisappear {
Task {
await self.dataStore.getBudgets()
}
}
}) })
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)
} }

View file

@ -26,9 +26,7 @@ struct TabbedBudgetView: View {
Button("budgets", action: { Button("budgets", action: {
self.dataStore.showBudgetSelection = true self.dataStore.showBudgetSelection = true
}).padding() }).padding()
}, trailing: Button("logout", action: { })
self.dataStore.logout()
}).padding())
} }
.tabItem { .tabItem {
Image(systemName: "chart.line.uptrend.xyaxis.circle.fill") Image(systemName: "chart.line.uptrend.xyaxis.circle.fill")

View file

@ -135,8 +135,12 @@
"onDate" = "On Date"; "onDate" = "On Date";
"email_invalid" = "Invalid email address"; "email_invalid" = "Invalid email address";
"cannot_be_empty" = "This cannot be empty"; "username_cannot_be_empty" = "Username cannot be empty";
"password_cannot_be_empty" = "Password cannot be empty";
"username_taken" = "Username taken"; "username_taken" = "Username taken";
"email_taken" = "Email taken"; "email_taken" = "Email taken";
"passwords_dont_match" = "Password don't match" "passwords_dont_match" = "Password don't match";
"unknown_error" = "An unknown error has occurred"; "unknown_error" = "An unknown error has occurred";
"server_error" = "Server error";
"server_invalid" = "Invalid server URL";
"credentials_invalid" = "Invalid username/password";

View file

@ -136,8 +136,12 @@
"onDate" = "Fecha"; "onDate" = "Fecha";
"email_invalid" = "Correo electrónico inválido"; "email_invalid" = "Correo electrónico inválido";
"cannot_be_empty" = "No se puede omitir"; "username_cannot_be_empty" = "Nombre de usuario no se puede omitir";
"password_cannot_be_empty" = "Contraseña no se puede omitir";
"username_taken" = "Nombre de usuario no disponible"; "username_taken" = "Nombre de usuario no disponible";
"email_taken" = "Correo electrónico no disponible"; "email_taken" = "Correo electrónico no disponible";
"passwords_dont_match" = "Contraseñas no coinciden" "passwords_dont_match" = "Contraseñas no coinciden";
"unknown_error" = "Se ocurrió un error"; "unknown_error" = "Se ocurrió un error";
"server_error" = "Error de servidor";
"server_invalid" = "URL de servidor inválido";
"credentials_invalid" = "Usuario/contraseña inválidos";