Nextcloud Login refactoring
This commit is contained in:
@@ -8,7 +8,14 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
/*
|
||||
import AuthenticationServices
|
||||
|
||||
|
||||
protocol LoginStage {
|
||||
func next() -> Self
|
||||
func previous() -> Self
|
||||
}
|
||||
|
||||
enum V2LoginStage: LoginStage {
|
||||
case login, validate
|
||||
|
||||
@@ -30,12 +37,27 @@ enum V2LoginStage: LoginStage {
|
||||
|
||||
|
||||
struct V2LoginView: View {
|
||||
@Binding var showAlert: Bool
|
||||
@Binding var alertMessage: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var showAlert: Bool = false
|
||||
@State var alertMessage: String = ""
|
||||
|
||||
@State var loginStage: V2LoginStage = .login
|
||||
@State var loginRequest: LoginV2Request? = nil
|
||||
@State var presentBrowser = false
|
||||
|
||||
@State var serverAddress: String = ""
|
||||
@State var serverProtocol: ServerProtocol = .https
|
||||
@State var loginPressed: Bool = false
|
||||
@State var isLoading: Bool = false
|
||||
|
||||
// Task reference for polling, to cancel if needed
|
||||
@State private var pollTask: Task<Void, Never>? = nil
|
||||
|
||||
enum ServerProtocol: String {
|
||||
case https="https://", http="http://"
|
||||
|
||||
static let all = [https, http]
|
||||
}
|
||||
|
||||
// TextField handling
|
||||
enum Field {
|
||||
@@ -45,114 +67,205 @@ struct V2LoginView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
ServerAddressField()
|
||||
CollapsibleView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.")
|
||||
.padding(.bottom)
|
||||
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.")
|
||||
.padding(.bottom)
|
||||
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
|
||||
}
|
||||
} title: {
|
||||
Text("Show help")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
}.padding()
|
||||
|
||||
if loginRequest != nil {
|
||||
Button("Copy Link") {
|
||||
UIPasteboard.general.string = loginRequest!.login
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
VStack {
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
if UserSettings.shared.serverAddress == "" {
|
||||
alertMessage = "Please enter a valid server address."
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let error = await sendLoginV2Request()
|
||||
if let error = error {
|
||||
alertMessage = "A network error occured (\(error.localizedDescription))."
|
||||
showAlert = true
|
||||
}
|
||||
if let loginRequest = loginRequest {
|
||||
presentBrowser = true
|
||||
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
|
||||
} else {
|
||||
alertMessage = "Unable to reach server. Please check your server address and internet connection."
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
loginStage = loginStage.next()
|
||||
} label: {
|
||||
Text("Login")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.white, lineWidth: 2)
|
||||
.foregroundColor(.clear)
|
||||
)
|
||||
}.padding()
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}.padding()
|
||||
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Server address:")
|
||||
TextField("example.com", text: $serverAddress)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
if loginStage == .validate {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
// fetch login v2 response
|
||||
Task {
|
||||
let (response, error) = await fetchLoginV2Response()
|
||||
checkLogin(response: response, error: error)
|
||||
}
|
||||
} label: {
|
||||
Text("Validate")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.white, lineWidth: 2)
|
||||
.foregroundColor(.clear)
|
||||
)
|
||||
Picker("Server Protocol:", selection: $serverProtocol) {
|
||||
ForEach(ServerProtocol.all, id: \.self) {
|
||||
Text($0.rawValue)
|
||||
}
|
||||
.disabled(loginRequest == nil ? true : false)
|
||||
.padding()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Login") {
|
||||
initiateLoginV2()
|
||||
}
|
||||
Spacer()
|
||||
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
|
||||
|
||||
} header: {
|
||||
Text("Nextcloud Login")
|
||||
} footer: {
|
||||
Text(
|
||||
"""
|
||||
The 'Login' button will open a web browser. Please follow the login instructions provided there.
|
||||
After a successful login, return to this application and press 'Validate'.
|
||||
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
|
||||
"""
|
||||
)
|
||||
}.disabled(loginPressed)
|
||||
|
||||
if let loginRequest = loginRequest {
|
||||
Section {
|
||||
Text(loginRequest.login)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Copy Link") {
|
||||
UIPasteboard.general.string = loginRequest.login
|
||||
}
|
||||
} footer: {
|
||||
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentBrowser, onDismiss: {
|
||||
Task {
|
||||
let (response, error) = await fetchLoginV2Response()
|
||||
checkLogin(response: response, error: error)
|
||||
.sheet(isPresented: $presentBrowser) {
|
||||
if let loginReq = loginRequest {
|
||||
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
print("Login completed with URL: \(url)")
|
||||
|
||||
dismiss()
|
||||
case .failure(let error):
|
||||
print("Login failed: \(error.localizedDescription)")
|
||||
self.alertMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
self.showAlert = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Error: Login URL not available.")
|
||||
}
|
||||
}) {
|
||||
if let loginRequest = loginRequest {
|
||||
WebViewSheet(url: loginRequest.login)
|
||||
}
|
||||
.alert("Error", isPresented: $showAlert) {
|
||||
Button("Copy Error") {
|
||||
print("Error copied: \(alertMessage)")
|
||||
UIPasteboard.general.string = alertMessage
|
||||
isLoading = false
|
||||
loginPressed = false
|
||||
}
|
||||
Button("Dismiss") {
|
||||
print("Error dismissed.")
|
||||
isLoading = false
|
||||
loginPressed = false
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func sendLoginV2Request() async -> NetworkError? {
|
||||
let (req, error) = await NextcloudApi.loginV2Request()
|
||||
self.loginRequest = req
|
||||
return error
|
||||
func initiateLoginV2() {
|
||||
isLoading = true
|
||||
loginPressed = true
|
||||
|
||||
Task {
|
||||
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
|
||||
|
||||
if let error = error {
|
||||
self.alertMessage = error.localizedDescription
|
||||
self.showAlert = true
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
return
|
||||
}
|
||||
|
||||
guard let req = req else {
|
||||
self.alertMessage = "Failed to get login URL from server."
|
||||
self.showAlert = true
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
return
|
||||
}
|
||||
|
||||
self.loginRequest = req
|
||||
|
||||
// Present the browser session
|
||||
presentBrowser = true
|
||||
|
||||
// Start polling in a separate task
|
||||
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
|
||||
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
|
||||
return await NextcloudApi.loginV2Response(req: loginRequest)
|
||||
func startPolling(pollURL: String, pollToken: String) {
|
||||
// Cancel any existing poll task first
|
||||
pollTask?.cancel()
|
||||
var pollingFailed = true
|
||||
|
||||
pollTask = Task {
|
||||
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
|
||||
for _ in 0..<maxRetries {
|
||||
if Task.isCancelled {
|
||||
print("Task cancelled.")
|
||||
break
|
||||
}
|
||||
|
||||
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
|
||||
|
||||
if Task.isCancelled {
|
||||
print("Task cancelled.")
|
||||
break
|
||||
}
|
||||
|
||||
if let response = response {
|
||||
// Success
|
||||
print("Task succeeded.")
|
||||
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
|
||||
pollingFailed = false
|
||||
|
||||
await MainActor.run {
|
||||
self.checkLogin(response: response, error: nil)
|
||||
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
}
|
||||
return
|
||||
} else if let error = error {
|
||||
if case .clientError(statusCode: 404) = error {
|
||||
// Continue polling
|
||||
print("Polling unsuccessful, continuing.")
|
||||
} else {
|
||||
// A more serious error occurred during polling
|
||||
print("Polling error: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
self.alertMessage = "Polling error: \(error.localizedDescription)"
|
||||
self.showAlert = true
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
isLoading = true
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// If polling finishes without success
|
||||
if !Task.isCancelled && pollingFailed {
|
||||
await MainActor.run {
|
||||
self.alertMessage = "Login timed out. Please try again."
|
||||
self.showAlert = true
|
||||
self.isLoading = false
|
||||
self.loginPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||
@@ -180,33 +293,72 @@ struct V2LoginView: View {
|
||||
|
||||
|
||||
|
||||
// Login WebView logic
|
||||
struct LoginBrowserView: UIViewControllerRepresentable {
|
||||
let authURL: URL
|
||||
let callbackURLScheme: String
|
||||
var completion: (Result<URL, Error>) -> Void
|
||||
|
||||
struct WebViewSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var url: String
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
UIViewController()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
WebView(url: URL(string: url)!)
|
||||
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
|
||||
.navigationBarItems(trailing: Button("Done") {
|
||||
dismiss()
|
||||
})
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||
|
||||
if !context.coordinator.sessionStarted {
|
||||
context.coordinator.sessionStarted = true
|
||||
|
||||
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
|
||||
context.coordinator.sessionStarted = false // Reset for potential retry
|
||||
if let callbackURL = callbackURL {
|
||||
completion(.success(callbackURL))
|
||||
} else if let error = error {
|
||||
completion(.failure(error))
|
||||
} else {
|
||||
// Handle unexpected nil URL and error
|
||||
completion(.failure(LoginError.unknownError))
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationContextProvider = context.coordinator
|
||||
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
session.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||
var parent: LoginBrowserView
|
||||
var sessionStarted: Bool = false // Prevent starting multiple sessions
|
||||
|
||||
init(_ parent: LoginBrowserView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||
return windowScene.windows.first!
|
||||
}
|
||||
return ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginError: Error, LocalizedError {
|
||||
case unknownError
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unknownError: return "An unknown error occurred during login."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
return WKWebView()
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
||||
let request = URLRequest(url: url)
|
||||
uiView.load(request)
|
||||
}
|
||||
#Preview {
|
||||
V2LoginView()
|
||||
}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user