import SwiftUI struct LoginView: View { @ObservedObject var auth: AuthStore @ObservedObject var settings: SettingsStore @State private var username = "" @State private var password = "" @State private var server = "" @State private var serverError: String? var body: some View { NavigationStack { Form { Section { Text("Sign in to Hope").font(.title2.bold()) Text("Use the account provided by your server administrator. Hermes is managed by your server.") .foregroundStyle(.secondary) } Section("Account") { TextField("Username", text: $username).textContentType(.username) .textInputAutocapitalization(.never).autocorrectionDisabled() SecureField("Password", text: $password).textContentType(.password) Button("Sign in") { let suppliedPassword = password password = "" Task { await auth.login(username: username, password: suppliedPassword) } } .disabled(auth.isBusy || username.isEmpty || password.isEmpty) if auth.isBusy { ProgressView("Signing in…") } if let error = auth.error { Text(error).foregroundStyle(.red) } } Section("Server") { TextField("HTTPS server address", text: $server).keyboardType(.URL) .textInputAutocapitalization(.never).autocorrectionDisabled() Button("Save server address") { switch ServerConfig.parse(server) { case .success(let config): settings.serverURL = config.baseURL.absoluteString case .failure(let error): serverError = error.localizedDescription } }.disabled(auth.isBusy) if let serverError { Text(serverError).foregroundStyle(.red) } Text("Connections use HTTPS and secure WebSockets. A private server certificate may require an installed, trusted CA on this iPhone.") .font(.footnote).foregroundStyle(.secondary) } } .navigationTitle("Hope") .onAppear { server = settings.serverURL } } } } struct AuthenticationRoot: View { @ObservedObject var settings: SettingsStore @State private var auth: AuthStore @State private var replacement: Task? init(settings: SettingsStore) { self.settings = settings _auth = State(initialValue: Self.makeAuth(settings)) } private static func makeAuth(_ settings: SettingsStore) -> AuthStore { guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { preconditionFailure("Invalid configured origin") } #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") { return AuthStore(config: config, tokens: MemoryTokenStore(), fixture: true) } #endif return AuthStore(config: config) } var body: some View { RootContent(settings: settings, auth: auth) .task(id: auth.config.baseURL) { await auth.restore() } .onChange(of: settings.serverURL) { _, _ in guard !auth.fixture else { return } guard replacement == nil else { return } replacement = Task { @MainActor in await auth.logout { await auth.disconnectAccount?() } guard !Task.isCancelled else { return } // Read latest origin after teardown; intermediate edits never // restore credentials or connect an intermediate account. auth = Self.makeAuth(settings) replacement = nil } } } } private struct RootContent: View { @ObservedObject var settings: SettingsStore @ObservedObject var auth: AuthStore var body: some View { if let user = auth.user { ConversationView(settings: settings, auth: auth, user: user, bearerToken: auth.token ?? "") .id(auth.config.baseURL.absoluteString + ":" + user.id) } else { LoginView(auth: auth, settings: settings) } } }