Implement error messages for login & registration
This commit is contained in:
parent
d72be0abd9
commit
5e1744f7d6
7 changed files with 174 additions and 79 deletions
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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";
|
||||||
|
|
Loading…
Reference in a new issue