Files
evgeniyChepelev 0327624b30 Multi-profiles (#80)
* fix layout on ipad

* Create multi profiles

* multi-profile server config, connection cache, and auth fixes

 Split ProfilesView into ProfilesListView, AddProfileSheet, ProfileBadge, AddProfileViewModel
- Add server URL + setup key fields to Add Profile screen
- Store per-profile connection data (ip/fqdn/managementURL) as typed model in ProfileConnectionCache
- Show cached connection info immediately on profile switch; empty if no prior data
- Fix profile deletion persistence via tombstone in profiles.json
- Fix logout to remove both netbird.cfg and state.json (cfg holds auth tokens)
- Preserve managementURL in UI after logout via cache fallback
- Guard polling from overwriting new profile's data during disconnect/reconnect cycle

* fix(multi-profile): reinitialize VPN adapter on profile switch and show current server URL

* Update project.pbxproj

* fix(multi-profile): prevent default server overwrite on logout/re-login

* Update PacketTunnelProvider.swift

* fix(multi-profile): address code review findings

- Normalize only scheme and host to lowercase when saving management
  server URL; previously the full URL was lowercased which could corrupt
  case-sensitive paths on self-hosted servers
- Move switchConnectionInfo(to:) inside the do-block so the connection
  UI is only updated after a successful profile switch
- Add ProfileConnectionCache.remove(for:) and clearConnectionData(for:)
  to prevent stale ip/fqdn/managementURL from persisting after a profile
  is deleted or logged out; call them from removeProfile and logoutProfile
- Use ASWebAuthenticationSession.Callback.customScheme on iOS 17.4+,
  falling back to the deprecated callbackURLScheme initializer on older
  versions; add a comment explaining why "http" is used and that a
  proper fix requires custom-scheme support in the SDK
- Guard presentationAnchor against a missing key window with
  assertionFailure in debug builds instead of silently returning an
  empty UIWindow
2026-04-22 10:06:51 +02:00

209 lines
7.8 KiB
Swift

//
// ServerView.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 12.10.23.
//
import SwiftUI
struct ServerView: View {
@EnvironmentObject var viewModel: ViewModel
@StateObject private var serverViewModel = ServerViewModel(configurationFilePath: Preferences.configFile() ?? "", deviceName: Device.getName())
private let defaultManagementServerUrl = "https://api.netbird.io"
@State private var showSetupKeyField = false
@State private var managementServerUrl = ""
@State private var setupKey = ""
@State private var isButtonDisabled = false
@State private var isAddDeviceToggleDisabled = false
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
Form {
Section(
header: Text("Server"),
footer: HStack(spacing: 6) {
Text("Current:")
.foregroundColor(Color("TextSecondary"))
Text(ProfileManager.shared.managementURL(for: ProfileManager.shared.getActiveProfileName()) ?? defaultManagementServerUrl)
.foregroundColor(Color("TextPrimary"))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.font(.footnote)
) {
ZStack(alignment: .leading) {
TextField("https://example-api.domain.com:443", text: $managementServerUrl)
.foregroundColor(Color("TextPrimary"))
.disableAutocorrection(true)
.autocapitalization(.none)
.keyboardType(.URL)
}
.onChange(of: managementServerUrl) { _ in
serverViewModel.clearErrorsFor(field: .url)
}
if let error = serverViewModel.viewErrors.urlError, !error.isEmpty {
Text(error).foregroundColor(.red).font(.footnote)
}
if let error = serverViewModel.viewErrors.generalError, !error.isEmpty {
Text(error).foregroundColor(.red).font(.footnote)
}
}
Section {
DisclosureGroup("Add this device with a setup key", isExpanded: $showSetupKeyField) {
TextField("0EF79C2F-DEE1-419B-BFC8-1BF529332998", text: $setupKey)
.disableAutocorrection(true)
.autocapitalization(.allCharacters)
.disabled(isAddDeviceToggleDisabled)
.onChange(of: setupKey) { _ in
serverViewModel.clearErrorsFor(field: .setupKey)
}
if let error = serverViewModel.viewErrors.setupKeyError, !error.isEmpty {
Text(error).foregroundColor(.red).font(.footnote)
}
if let error = serverViewModel.viewErrors.ssoNotSupportedError, !error.isEmpty {
Text(error).foregroundColor(.red).font(.footnote)
}
Text("Using setup keys for user devices is not recommended. SSO with MFA provides stronger security, proper user-device association, and periodic re-authentication.")
.font(.footnote)
.foregroundColor(.accentColor)
.padding(.vertical, 4)
}
}
.onChange(of: showSetupKeyField) { expanded in
if !expanded {
setupKey = ""
serverViewModel.clearErrorsFor(field: .setupKey)
}
}
Section {
if isButtonDisabled {
HStack {
Spacer()
ProgressView()
.padding(.trailing, 8)
Text("Validating...")
Spacer()
}
} else {
Button {
dismissKeyboard()
performChange()
} label: {
HStack {
Spacer()
Text("Change")
Spacer()
}
}
}
}
Section {
Button {
dismissKeyboard()
managementServerUrl = defaultManagementServerUrl
performUseNetBird()
} label: {
HStack {
Spacer()
Image("icon-netbird-button")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
Text("Use NetBird server")
Spacer()
}
}
.disabled(isButtonDisabled)
}
}
.navigationTitle("Change Server")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: serverViewModel.viewErrors.ssoNotSupportedError) { error in
if error != nil {
showSetupKeyField = true
}
}
.onChange(of: serverViewModel.isOperationSuccessful) { success in
if success {
presentationMode.wrappedValue.dismiss()
viewModel.showServerChangedInfo = true
}
}
.onChange(of: serverViewModel.isUiEnabled) { isEnabled in
if isEnabled {
isButtonDisabled = false
isAddDeviceToggleDisabled = false
} else {
isButtonDisabled = true
isAddDeviceToggleDisabled = true
}
}
}
private func dismissKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
private func performChange() {
if managementServerUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& setupKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return
}
let rawUrl = managementServerUrl.trimmingCharacters(in: .whitespacesAndNewlines)
var urlComponents = URLComponents(string: rawUrl)
if let scheme = urlComponents?.scheme { urlComponents?.scheme = scheme.lowercased() }
if let host = urlComponents?.host { urlComponents?.host = host.lowercased() }
var serverUrl = urlComponents?.string ?? rawUrl
if serverUrl.isEmpty {
serverUrl = defaultManagementServerUrl
}
managementServerUrl = serverUrl
let key = setupKey.trimmingCharacters(in: .whitespacesAndNewlines)
serverViewModel.clearErrorsFor(field: .all)
Task {
await Task.yield()
if !serverUrl.isEmpty && !key.isEmpty {
await serverViewModel.loginWithSetupKey(managementServerUrl: serverUrl, setupKey: key)
} else if !serverUrl.isEmpty {
await serverViewModel.changeManagementServerAddress(managementServerUrl: serverUrl)
}
}
}
private func performUseNetBird() {
let serverUrl = defaultManagementServerUrl
let key = setupKey.trimmingCharacters(in: .whitespacesAndNewlines)
serverViewModel.clearErrorsFor(field: .all)
Task {
await Task.yield()
if key.isEmpty {
await serverViewModel.changeManagementServerAddress(managementServerUrl: serverUrl)
} else {
await serverViewModel.loginWithSetupKey(managementServerUrl: serverUrl, setupKey: key)
}
}
}
}
#Preview {
ServerView()
.environmentObject(ViewModel())
}