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

218 lines
7.8 KiB
Swift

//
// ProfilesListView.swift
// NetBird
//
import SwiftUI
#if os(iOS)
struct ProfilesListView: View {
@EnvironmentObject var viewModel: ViewModel
@State private var profiles: [Profile] = []
@State private var showAddSheet = false
@State private var showSwitchAlert = false
@State private var showRemoveAlert = false
@State private var showLogoutAlert = false
@State private var showErrorAlert = false
@State private var errorMessage = ""
@State private var selectedProfile: Profile?
private var activeProfile: Profile? {
profiles.first(where: { $0.isActive })
}
private var inactiveProfiles: [Profile] {
profiles.filter { !$0.isActive }
}
var body: some View {
List {
if let active = activeProfile {
Section("Active") {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(active.name)
.font(.body.bold())
.foregroundColor(Color("TextPrimary"))
if let url = ProfileManager.shared.managementURL(for: active.name) {
Text(url)
.font(.footnote)
.foregroundColor(Color("TextSecondary"))
}
}
Spacer()
Text("Active")
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green)
.clipShape(Capsule())
}
}
}
Section("All Profiles") {
if inactiveProfiles.isEmpty {
VStack(spacing: 8) {
Image(systemName: "person.2.slash")
.font(.title2)
.foregroundColor(Color("TextSecondary"))
Text("No Additional Profiles")
.font(.subheadline.bold())
.foregroundColor(Color("TextPrimary"))
Text("Tap + to add a new profile")
.font(.footnote)
.foregroundColor(Color("TextSecondary"))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
} else {
ForEach(inactiveProfiles) { profile in
Button {
selectedProfile = profile
showSwitchAlert = true
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.body)
.foregroundColor(Color("TextPrimary"))
if let url = ProfileManager.shared.managementURL(for: profile.name) {
Text(url)
.font(.footnote)
.foregroundColor(Color("TextSecondary"))
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if profile.name != "default" {
Button(role: .destructive) {
selectedProfile = profile
showRemoveAlert = true
} label: {
Label("Remove", systemImage: "trash")
}
}
Button {
selectedProfile = profile
showLogoutAlert = true
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
.tint(.gray)
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Profiles")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
}
}
.onAppear {
loadProfiles()
}
.sheet(isPresented: $showAddSheet) {
AddProfileSheet {
loadProfiles()
}
}
.alert("Switch Profile", isPresented: $showSwitchAlert, presenting: selectedProfile) { profile in
Button("Cancel", role: .cancel) {}
Button("Switch", role: .destructive) {
switchToProfile(profile)
}
} message: { profile in
Text("VPN will be disconnected to switch to \u{00AB}\(profile.name)\u{00BB}. Continue?")
}
.alert("Remove Profile", isPresented: $showRemoveAlert, presenting: selectedProfile) { profile in
Button("Cancel", role: .cancel) {}
Button("Remove", role: .destructive) {
removeProfile(profile)
}
} message: { profile in
Text("Profile \u{00AB}\(profile.name)\u{00BB} and all its data will be deleted. This action cannot be undone.")
}
.alert("Logout from Profile", isPresented: $showLogoutAlert, presenting: selectedProfile) { profile in
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
logoutProfile(profile)
}
} message: { profile in
Text("You will need to re-authenticate to use profile \u{00AB}\(profile.name)\u{00BB} again.")
}
.alert("Error", isPresented: $showErrorAlert) {
Button("OK") {}
} message: {
Text(errorMessage)
}
}
// MARK: - Actions
private func loadProfiles() {
profiles = ProfileManager.shared.listProfiles()
}
private func switchToProfile(_ profile: Profile) {
viewModel.performClose()
do {
try ProfileManager.shared.switchProfile(profile.name)
viewModel.switchConnectionInfo(to: profile.name)
viewModel.reloadConfiguration()
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName()
if let url = ProfileManager.shared.managementURL(for: profile.name) {
Preferences.saveManagementURL(url)
}
loadProfiles()
} catch {
errorMessage = error.localizedDescription
showErrorAlert = true
}
}
private func removeProfile(_ profile: Profile) {
do {
try ProfileManager.shared.removeProfile(profile.name)
loadProfiles()
} catch {
errorMessage = error.localizedDescription
showErrorAlert = true
}
}
private func logoutProfile(_ profile: Profile) {
if profile.isActive {
viewModel.performClose()
}
do {
try ProfileManager.shared.logoutProfile(profile.name)
loadProfiles()
} catch {
errorMessage = error.localizedDescription
showErrorAlert = true
}
}
}
#Preview {
NavigationView {
ProfilesListView()
.environmentObject(ViewModel())
}
}
#endif