diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift index 505c01f..9f60bb1 100644 --- a/Forji/Forji/Services/AuthenticationService.swift +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -19,6 +19,29 @@ class AuthenticationService { try await storeCredentials(result: result, password: password) } + func loginWithToken(serverURL: String, token: String, allowSelfSigned: Bool = false) async throws { + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + let tokenClient = ForgejoClient( + serverURL: normalizedURL, + username: "", + token: token, + allowSelfSignedCertificates: allowSelfSigned, + ) + let user = try await tokenClient.fetchCurrentUser() + let tokenClientWithUsername = ForgejoClient( + serverURL: normalizedURL, + username: user.login, + token: token, + allowSelfSignedCertificates: allowSelfSigned, + ) + try await KeychainManager.shared.saveToken( + token, for: normalizedURL, username: user.login, + ) + client = tokenClientWithUsername + currentUser = user + isAuthenticated = true + } + func loginWithOTP( serverURL: String, username: String, password: String, otp: String, allowSelfSigned: Bool = false, diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift index 262b7c8..41305ff 100644 --- a/Forji/Forji/Views/InstanceFormView.swift +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -10,11 +10,16 @@ struct InstanceFormView: View { var id: String { switch self { case .add: "add" - case let .edit(instance): instance.serverURL + instance.username + case .edit(let instance): instance.serverURL + instance.username } } } + 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] @@ -29,10 +34,12 @@ 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 = "" @@ -49,7 +56,7 @@ struct InstanceFormView: View { var body: some View { NavigationStack { Form { - Section { + Section("Forgejo Instance") { TextField("Name (optional)", text: $name) .autocorrectionDisabled() .accessibilityIdentifier("instance-name-field") @@ -60,36 +67,63 @@ struct InstanceFormView: View { .autocorrectionDisabled() .keyboardType(.URL) .accessibilityIdentifier("login-serverURL-field") + } - TextField("Username", text: $username) - .textContentType(.username) - .autocapitalization(.none) - .autocorrectionDisabled() - .accessibilityIdentifier("login-username-field") + Section { + if authMode == .credentials { + TextField("Username", text: $username) + .textContentType(.username) + .autocapitalization(.none) + .autocorrectionDisabled() + .accessibilityIdentifier("login-username-field") - SecureField("Password", text: $password) - .textContentType(.password) - .accessibilityIdentifier("login-password-field") + SecureField("Password", text: $password) + .textContentType(.password) + .accessibilityIdentifier("login-password-field") - if needsOTP { - TextField("One-Time Code", text: $otpCode) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .accessibilityIdentifier("login-otp-field") + if needsOTP { + TextField("One-Time Code", text: $otpCode) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("login-otp-field") + } + } else { + SecureField("API Token", text: $apiToken) + .textContentType(.password) + .autocorrectionDisabled() + .accessibilityIdentifier("login-token-field") } } header: { - Text("Forgejo Instance") - } footer: { - if needsOTP { - Text("Enter the 6-digit code from your authenticator app.") - } else { - switch mode { - case .add: - Text("Enter your Forgejo server URL (e.g., forgejo.example.com)") - case .edit: - Text("Leave password blank to keep the existing one.") + VStack(alignment: .leading, spacing: 8) { + Text("Credentials") + if isAddMode { + Picker("Login Method", selection: $authMode) { + ForEach(AuthMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) } } + .textCase(nil) + .padding(.bottom, 4) + } footer: { + if authMode == .token { + Text( + """ + Generate a token under Settings → Applications with the following scopes: + • read/write:user + • read/write:repository + • read/write:issue + • read/write:notification + • read:organization + """ + ) + } else if 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.") + } } Section { @@ -122,8 +156,11 @@ struct InstanceFormView: View { } .buttonStyle(.borderedProminent) .disabled( - isLoading || serverURL.isEmpty || username.isEmpty - || (isAddMode && password.isEmpty) || (needsOTP && otpCode.isEmpty), + isLoading || serverURL.isEmpty + || (authMode == .credentials + && (username.isEmpty || (isAddMode && password.isEmpty) + || (needsOTP && otpCode.isEmpty))) + || (authMode == .token && apiToken.isEmpty), ) .listRowBackground(Color.clear) .accessibilityIdentifier("login-button") @@ -157,7 +194,7 @@ struct InstanceFormView: View { if !devPassword.isEmpty { password = devPassword } allowSelfSignedCertificates = devAllowSelfSigned #endif - case let .edit(instance): + case .edit(let instance): name = instance.name serverURL = instance.serverURL username = instance.username @@ -167,21 +204,30 @@ struct InstanceFormView: View { } private func performLogin() async throws { - if needsOTP, !otpCode.isEmpty { - try await authService.loginWithOTP( + switch authMode { + case .token: + try await authService.loginWithToken( serverURL: serverURL, - username: username, - password: password, - otp: otpCode, - allowSelfSigned: allowSelfSignedCertificates, - ) - } else { - try await authService.login( - serverURL: serverURL, - username: username, - password: password, + 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, + ) + } } } @@ -203,9 +249,11 @@ struct InstanceFormView: View { } let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + let resolvedUsername = + authMode == .token ? (authService.currentUser?.login ?? username) : username let instance = ForgejoInstance( serverURL: normalizedURL, - username: username, + username: resolvedUsername, name: name, isDefault: isDefault, allowSelfSignedCertificates: allowSelfSignedCertificates, @@ -218,7 +266,7 @@ struct InstanceFormView: View { } authService.currentInstance = instance - case let .edit(instance): + case .edit(let instance): if !password.isEmpty { try await performLogin() } @@ -257,13 +305,13 @@ struct InstanceFormView: View { } #if DEBUG -#Preview("Add") { - InstanceFormView(authService: .previewDefault, mode: .add) - .modelContainer(for: ForgejoInstance.self, inMemory: true) -} + #Preview("Add") { + InstanceFormView(authService: .previewDefault, mode: .add) + .modelContainer(for: ForgejoInstance.self, inMemory: true) + } -#Preview("Edit") { - InstanceFormView(authService: .previewDefault, mode: .edit(.preview)) - .modelContainer(for: ForgejoInstance.self, inMemory: true) -} + #Preview("Edit") { + InstanceFormView(authService: .previewDefault, mode: .edit(.preview)) + .modelContainer(for: ForgejoInstance.self, inMemory: true) + } #endif