diff --git a/Forji/Forji/ViewModels/InstanceFormViewModel.swift b/Forji/Forji/ViewModels/InstanceFormViewModel.swift new file mode 100644 index 0000000..d668804 --- /dev/null +++ b/Forji/Forji/ViewModels/InstanceFormViewModel.swift @@ -0,0 +1,123 @@ +import Combine +import ForgejoKit +import Foundation + +/// View model for ``InstanceFormView``. +/// +/// Owns the login form's field state, validation, and the authentication action, +/// keeping the view thin. The auth service is injected so the model can be tested +/// against a fake without reaching for a global singleton. +@MainActor +final class InstanceFormViewModel: ObservableObject { + enum AuthMode: String, CaseIterable { + case credentials = "Password" + case token = "API Token" + } + + @Published var name: String + @Published var serverURL: String + @Published var username: String + @Published var password: String + @Published var apiToken: String + @Published var otpCode = "" + @Published var authMode: AuthMode + @Published var allowSelfSignedCertificates: Bool + @Published var isDefault: Bool + @Published var needsOTP = false + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showError = false + + let authService: AuthenticationService + + init( + authService: AuthenticationService, + name: String = "", + serverURL: String = "", + username: String = "", + password: String = "", + apiToken: String = "", + authMode: AuthMode = .credentials, + allowSelfSignedCertificates: Bool = false, + isDefault: Bool = false, + ) { + self.authService = authService + self.name = name + self.serverURL = serverURL + self.username = username + self.password = password + self.apiToken = apiToken + self.authMode = authMode + self.allowSelfSignedCertificates = allowSelfSignedCertificates + self.isDefault = isDefault + } + + /// Whether the submit button should be enabled for the given mode. + func canSubmit(isAddMode: Bool) -> Bool { + guard !isLoading, !serverURL.isEmpty else { return false } + switch authMode { + case .credentials: + if username.isEmpty { return false } + if isAddMode, password.isEmpty { return false } + if needsOTP, otpCode.isEmpty { return false } + return true + case .token: + return !(isAddMode && apiToken.isEmpty) + } + } + + /// Authenticates with the current field values. Returns `true` on success. + /// + /// Manages `isLoading` and surfaces failures via `errorMessage`/`showError`. + /// An OTP challenge flips `needsOTP` and returns `false` so the view can prompt. + func authenticate() async -> Bool { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await performLogin() + return true + } catch AuthenticationError.otpRequired { + needsOTP = true + return false + } catch { + errorMessage = error.localizedDescription + showError = true + return false + } + } + + /// Shows a validation error without attempting authentication. + func presentError(_ message: String) { + errorMessage = message + showError = true + } + + private func performLogin() async throws { + switch authMode { + case .token: + try await authService.loginWithToken( + serverURL: serverURL, + token: apiToken, + allowSelfSigned: allowSelfSignedCertificates, + ) + case .credentials: + if needsOTP, !otpCode.isEmpty { + try await authService.loginWithOTP( + serverURL: serverURL, + username: username, + password: password, + otp: otpCode, + allowSelfSigned: allowSelfSignedCertificates, + ) + } else { + try await authService.login( + serverURL: serverURL, + username: username, + password: password, + allowSelfSigned: allowSelfSignedCertificates, + ) + } + } + } +} diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift index 716344b..ec423a7 100644 --- a/Forji/Forji/Views/InstanceFormView.swift +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -2,7 +2,6 @@ import ForgejoKit import SwiftData import SwiftUI -// swiftlint:disable:next type_body_length struct InstanceFormView: View { enum Mode: Identifiable { case add @@ -16,15 +15,10 @@ struct InstanceFormView: View { } } - enum AuthMode: String, CaseIterable { - case credentials = "Password" - case token = "API Token" - } - @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance] - @State private var authService: AuthenticationService + @StateObject private var viewModel: InstanceFormViewModel let mode: Mode @@ -36,34 +30,20 @@ struct InstanceFormView: View { @AppStorage("dev_allowSelfSigned") private var devAllowSelfSigned = false #endif - @State private var authMode: AuthMode = .credentials - @State private var name = "" - @State private var serverURL = "" - @State private var username = "" - @State private var password = "" - @State private var apiToken = "" - @State private var allowSelfSignedCertificates = false - @State private var isDefault = false - @State private var otpCode = "" - @State private var needsOTP = false - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showError = false - init(authService: AuthenticationService, mode: Mode) { - self.authService = authService self.mode = mode + _viewModel = StateObject(wrappedValue: InstanceFormViewModel(authService: authService)) } var body: some View { NavigationStack { Form { Section("Forgejo Instance") { - TextField("Name (optional)", text: $name) + TextField("Name (optional)", text: $viewModel.name) .autocorrectionDisabled() .accessibilityIdentifier("instance-name-field") - TextField("Server URL", text: $serverURL) + TextField("Server URL", text: $viewModel.serverURL) .textContentType(.URL) .autocapitalization(.none) .autocorrectionDisabled() @@ -72,25 +52,25 @@ struct InstanceFormView: View { } Section { - if authMode == .credentials { - TextField("Username", text: $username) + if viewModel.authMode == .credentials { + TextField("Username", text: $viewModel.username) .textContentType(.username) .autocapitalization(.none) .autocorrectionDisabled() .accessibilityIdentifier("login-username-field") - SecureField("Password", text: $password) + SecureField("Password", text: $viewModel.password) .textContentType(.password) .accessibilityIdentifier("login-password-field") - if needsOTP { - TextField("One-Time Code", text: $otpCode) + if viewModel.needsOTP { + TextField("One-Time Code", text: $viewModel.otpCode) .keyboardType(.numberPad) .textContentType(.oneTimeCode) .accessibilityIdentifier("login-otp-field") } } else { - SecureField("API Token", text: $apiToken) + SecureField("API Token", text: $viewModel.apiToken) .textContentType(.password) .autocorrectionDisabled() .accessibilityIdentifier("login-token-field") @@ -98,9 +78,9 @@ struct InstanceFormView: View { } header: { VStack(alignment: .leading, spacing: 8) { Text("Credentials") - Picker("Login Method", selection: $authMode) { - ForEach(AuthMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) + Picker("Login Method", selection: $viewModel.authMode) { + ForEach(InstanceFormViewModel.AuthMode.allCases, id: \.self) { authOption in + Text(authOption.rawValue).tag(authOption) } } .pickerStyle(.segmented) @@ -108,9 +88,9 @@ struct InstanceFormView: View { .textCase(nil) .padding(.bottom, 4) } footer: { - if authMode == .token, case .edit = mode { + if viewModel.authMode == .token, case .edit = mode { Text("Leave token blank to keep the existing one.") - } else if authMode == .token { + } else if viewModel.authMode == .token { Text( """ Generate a token under Settings → Applications with the following scopes: @@ -121,7 +101,7 @@ struct InstanceFormView: View { • read:organization """, ) - } else if needsOTP { + } else if viewModel.needsOTP { Text("Enter the 6-digit code from your authenticator app.") } else if case .edit = mode { Text("Leave password blank to keep the existing one.") @@ -129,14 +109,14 @@ struct InstanceFormView: View { } Section { - Toggle("Accept Self-Signed Certificates", isOn: $allowSelfSignedCertificates) + Toggle("Accept Self-Signed Certificates", isOn: $viewModel.allowSelfSignedCertificates) } footer: { Text("Enable this if your Forgejo instance uses a self-signed SSL certificate.") .font(.caption) } Section { - Toggle("Default Instance", isOn: $isDefault) + Toggle("Default Instance", isOn: $viewModel.isDefault) } footer: { Text("The default instance will be connected automatically on launch.") .font(.caption) @@ -145,16 +125,10 @@ struct InstanceFormView: View { Section { LoadingActionButton( title: isAddMode ? "Login" : "Save", - isLoading: isLoading, + isLoading: viewModel.isLoading, action: handleSave, ) - .disabled( - isLoading || serverURL.isEmpty - || (authMode == .credentials - && (username.isEmpty || (isAddMode && password.isEmpty) - || (needsOTP && otpCode.isEmpty))) - || (authMode == .token && isAddMode && apiToken.isEmpty), - ) + .disabled(!viewModel.canSubmit(isAddMode: isAddMode)) .listRowBackground(Color.clear) .accessibilityIdentifier("login-button") } @@ -166,7 +140,7 @@ struct InstanceFormView: View { Button("Cancel") { dismiss() } } } - .errorAlert("Login Failed", message: $errorMessage, isPresented: $showError) + .errorAlert("Login Failed", message: $viewModel.errorMessage, isPresented: $viewModel.showError) .onAppear { populateFields() } @@ -178,145 +152,105 @@ struct InstanceFormView: View { return false } - private func populateFields() { - switch mode { - case .add: - #if DEBUG - if !devServerURL.isEmpty { serverURL = devServerURL } - if !devUsername.isEmpty { username = devUsername } - if !devPassword.isEmpty { password = devPassword } - if !devApiToken.isEmpty { apiToken = devApiToken } - allowSelfSignedCertificates = devAllowSelfSigned - #endif - case let .edit(instance): - name = instance.name - serverURL = instance.serverURL - username = instance.username - allowSelfSignedCertificates = instance.allowSelfSignedCertificates - isDefault = instance.isDefault - authMode = instance.useTokenAuth ? .token : .credentials - } - } - - private func performLogin() async throws { - switch authMode { - case .token: - try await authService.loginWithToken( - serverURL: serverURL, - token: apiToken, - allowSelfSigned: allowSelfSignedCertificates, - ) - case .credentials: - if needsOTP, !otpCode.isEmpty { - try await authService.loginWithOTP( - serverURL: serverURL, - username: username, - password: password, - otp: otpCode, - allowSelfSigned: allowSelfSignedCertificates, - ) - } else { - try await authService.login( - serverURL: serverURL, - username: username, - password: password, - allowSelfSigned: allowSelfSignedCertificates, - ) - } - } - } - private var editingInstanceId: String? { if case let .edit(instance) = mode { return instance.serverURL + instance.username } return nil } - // swiftlint:disable:next function_body_length cyclomatic_complexity + private func populateFields() { + switch mode { + case .add: + #if DEBUG + if !devServerURL.isEmpty { viewModel.serverURL = devServerURL } + if !devUsername.isEmpty { viewModel.username = devUsername } + if !devPassword.isEmpty { viewModel.password = devPassword } + if !devApiToken.isEmpty { viewModel.apiToken = devApiToken } + viewModel.allowSelfSignedCertificates = devAllowSelfSigned + #endif + case let .edit(instance): + viewModel.name = instance.name + viewModel.serverURL = instance.serverURL + viewModel.username = instance.username + viewModel.allowSelfSignedCertificates = instance.allowSelfSignedCertificates + viewModel.isDefault = instance.isDefault + viewModel.authMode = instance.useTokenAuth ? .token : .credentials + } + } + private func handleSave() { - if !name.isEmpty { - let conflict = instances.first { inst in - let instId = inst.serverURL + inst.username - return inst.name == name && instId != editingInstanceId - } - if conflict != nil { - errorMessage = "An instance named \"\(name)\" already exists. Please choose a different name." - showError = true - return - } + if let conflictMessage = nameConflictMessage() { + viewModel.presentError(conflictMessage) + return } - isLoading = true - errorMessage = nil - Task { - do { - switch mode { - case .add: - try await performLogin() - - if isDefault { - for inst in instances where inst.isDefault { - inst.isDefault = false - } - } - - let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) - let resolvedUsername = - authMode == .token ? (authService.currentUser?.login ?? username) : username - let instance = ForgejoInstance( - serverURL: normalizedURL, - username: resolvedUsername, - name: name, - isDefault: isDefault, - allowSelfSignedCertificates: allowSelfSignedCertificates, - useTokenAuth: authMode == .token, - ) - modelContext.insert(instance) - do { - try modelContext.save() - } catch { - assertionFailure("SwiftData save failed: \(error)") - } - authService.currentInstance = instance - - case let .edit(instance): - if authMode == .token, !apiToken.isEmpty { - try await performLogin() - instance.username = authService.currentUser?.login ?? username - } else if authMode == .credentials, !password.isEmpty { - try await performLogin() - instance.username = username - } - - instance.name = name - instance.serverURL = ForgejoClient.normalizeServerURL(serverURL) - instance.allowSelfSignedCertificates = allowSelfSignedCertificates - instance.useTokenAuth = authMode == .token - - if isDefault, !instance.isDefault { - for inst in instances where inst.isDefault { - inst.isDefault = false - } - } - instance.isDefault = isDefault - - do { - try modelContext.save() - } catch { - assertionFailure("SwiftData save failed: \(error)") - } - authService.currentInstance = instance - } - + switch mode { + case .add: + guard await viewModel.authenticate() else { return } + if viewModel.isDefault { clearOtherDefaults() } + let instance = ForgejoInstance( + serverURL: ForgejoClient.normalizeServerURL(viewModel.serverURL), + username: resolvedUsername, + name: viewModel.name, + isDefault: viewModel.isDefault, + allowSelfSignedCertificates: viewModel.allowSelfSignedCertificates, + useTokenAuth: viewModel.authMode == .token, + ) + modelContext.insert(instance) + saveContext() + viewModel.authService.currentInstance = instance dismiss() - } catch AuthenticationError.otpRequired { - needsOTP = true - } catch { - errorMessage = error.localizedDescription - showError = true - } - isLoading = false + case let .edit(instance): + if editModeNeedsAuth { + guard await viewModel.authenticate() else { return } + instance.username = resolvedUsername + } + instance.name = viewModel.name + instance.serverURL = ForgejoClient.normalizeServerURL(viewModel.serverURL) + instance.allowSelfSignedCertificates = viewModel.allowSelfSignedCertificates + instance.useTokenAuth = viewModel.authMode == .token + if viewModel.isDefault, !instance.isDefault { clearOtherDefaults() } + instance.isDefault = viewModel.isDefault + saveContext() + viewModel.authService.currentInstance = instance + dismiss() + } + } + } + + private var editModeNeedsAuth: Bool { + (viewModel.authMode == .token && !viewModel.apiToken.isEmpty) + || (viewModel.authMode == .credentials && !viewModel.password.isEmpty) + } + + private var resolvedUsername: String { + viewModel.authMode == .token + ? (viewModel.authService.currentUser?.login ?? viewModel.username) + : viewModel.username + } + + private func nameConflictMessage() -> String? { + guard !viewModel.name.isEmpty else { return nil } + let conflict = instances.first { inst in + let instId = inst.serverURL + inst.username + return inst.name == viewModel.name && instId != editingInstanceId + } + guard conflict != nil else { return nil } + return "An instance named \"\(viewModel.name)\" already exists. Please choose a different name." + } + + private func clearOtherDefaults() { + for inst in instances where inst.isDefault { + inst.isDefault = false + } + } + + private func saveContext() { + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") } } } diff --git a/Forji/ForjiTests/InstanceFormViewModelTests.swift b/Forji/ForjiTests/InstanceFormViewModelTests.swift new file mode 100644 index 0000000..e432f43 --- /dev/null +++ b/Forji/ForjiTests/InstanceFormViewModelTests.swift @@ -0,0 +1,58 @@ +import ForgejoKit +@testable import Forji +import Foundation +import Testing + +@MainActor +struct InstanceFormViewModelTests { + /// Stand-in for the real service: overrides the auth entry points so the + /// view model can be exercised without touching the network or keychain. + final class FakeAuthService: AuthenticationService { + var loginErrorToThrow: Error? + private(set) var loginCallCount = 0 + + override func login( + serverURL _: String, + username _: String, + password _: String, + allowSelfSigned _: Bool = false, + ) async throws { + loginCallCount += 1 + if let loginErrorToThrow { throw loginErrorToThrow } + isAuthenticated = true + } + } + + @Test func loginSuccessReportsSuccessAndClearsLoading() async { + let fake = FakeAuthService() + let viewModel = InstanceFormViewModel(authService: fake) + viewModel.serverURL = "https://forgejo.example.com" + viewModel.username = "alice" + viewModel.password = "hunter2" + + let succeeded = await viewModel.authenticate() + + #expect(succeeded) + #expect(fake.loginCallCount == 1) + #expect(viewModel.isLoading == false) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.showError == false) + } + + @Test func loginFailureSurfacesErrorAndStopsLoading() async { + struct LoginBoom: LocalizedError { var errorDescription: String? { "boom" } } + let fake = FakeAuthService() + fake.loginErrorToThrow = LoginBoom() + let viewModel = InstanceFormViewModel(authService: fake) + viewModel.serverURL = "https://forgejo.example.com" + viewModel.username = "alice" + viewModel.password = "wrong" + + let succeeded = await viewModel.authenticate() + + #expect(succeeded == false) + #expect(viewModel.isLoading == false) + #expect(viewModel.showError) + #expect(viewModel.errorMessage == "boom") + } +}