diff --git a/bookstax/Models/ServerProfile.swift b/bookstax/Models/ServerProfile.swift new file mode 100644 index 0000000..5db7a62 --- /dev/null +++ b/bookstax/Models/ServerProfile.swift @@ -0,0 +1,137 @@ +import Foundation +import Observation + +// MARK: - ServerProfile + +struct ServerProfile: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var serverURL: String +} + +// MARK: - ServerProfileStore + +@Observable +final class ServerProfileStore { + static let shared = ServerProfileStore() + + private(set) var profiles: [ServerProfile] = [] + private(set) var activeProfileId: UUID? + + var activeProfile: ServerProfile? { + profiles.first { $0.id == activeProfileId } + } + + private let profilesKey = "serverProfiles" + private let activeIdKey = "activeProfileId" + + private init() { + load() + migrate() + } + + // MARK: - Add + + /// Adds a new profile and persists its credentials synchronously. + func addProfile(_ profile: ServerProfile, tokenId: String, tokenSecret: String) { + KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id) + profiles.append(profile) + save() + activate(profile) + } + + // MARK: - Activate + + func activate(_ profile: ServerProfile) { + guard let creds = KeychainService.loadCredentialsSync(profileId: profile.id) else { return } + activeProfileId = profile.id + UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey) + // Keep legacy "serverURL" key in sync for BookStackAPI + UserDefaults.standard.set(profile.serverURL, forKey: "serverURL") + Task { + await BookStackAPI.shared.configure( + serverURL: profile.serverURL, + tokenId: creds.tokenId, + tokenSecret: creds.tokenSecret + ) + } + } + + // MARK: - Remove + + func remove(_ profile: ServerProfile) { + profiles.removeAll { $0.id == profile.id } + KeychainService.deleteCredentialsSync(profileId: profile.id) + save() + if activeProfileId == profile.id { + activeProfileId = nil + UserDefaults.standard.removeObject(forKey: activeIdKey) + UserDefaults.standard.removeObject(forKey: "serverURL") + } + } + + // MARK: - Update + + /// Updates the name/URL of an existing profile. Optionally rotates credentials. + func updateProfile(_ profile: ServerProfile, newName: String, newURL: String, + newTokenId: String? = nil, newTokenSecret: String? = nil) { + guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return } + profiles[idx].name = newName + profiles[idx].serverURL = newURL + if let id = newTokenId, let secret = newTokenSecret { + KeychainService.saveCredentialsSync(tokenId: id, tokenSecret: secret, profileId: profile.id) + } + save() + // If this is the active profile, re-configure the API client + if activeProfileId == profile.id { + UserDefaults.standard.set(newURL, forKey: "serverURL") + let creds = (newTokenId != nil && newTokenSecret != nil) + ? (tokenId: newTokenId!, tokenSecret: newTokenSecret!) + : KeychainService.loadCredentialsSync(profileId: profile.id) ?? (tokenId: "", tokenSecret: "") + Task { + await BookStackAPI.shared.configure( + serverURL: newURL, + tokenId: creds.tokenId, + tokenSecret: creds.tokenSecret + ) + } + } + } + + // MARK: - Persistence + + private func save() { + guard let data = try? JSONEncoder().encode(profiles) else { return } + UserDefaults.standard.set(data, forKey: profilesKey) + } + + private func load() { + if let data = UserDefaults.standard.data(forKey: profilesKey), + let decoded = try? JSONDecoder().decode([ServerProfile].self, from: data) { + profiles = decoded + } + if let idString = UserDefaults.standard.string(forKey: activeIdKey), + let uuid = UUID(uuidString: idString) { + activeProfileId = uuid + } + } + + // MARK: - Migration from legacy single-server config + + private func migrate() { + guard profiles.isEmpty, + let url = UserDefaults.standard.string(forKey: "serverURL"), + !url.isEmpty, + let tokenId = KeychainService.loadSync(key: "tokenId"), + let tokenSecret = KeychainService.loadSync(key: "tokenSecret") else { return } + + let profile = ServerProfile(id: UUID(), name: "BookStack", serverURL: url) + KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id) + profiles.append(profile) + save() + activeProfileId = profile.id + UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey) + // Leave legacy "serverURL" in place so BookStackAPI continues working after migration. + AppLog(.info, "Migrated legacy server config to profile \(profile.id)", category: "ServerProfile") + } +} diff --git a/bookstax/Services/KeychainService.swift b/bookstax/Services/KeychainService.swift index 51f4ff6..4955c7e 100644 --- a/bookstax/Services/KeychainService.swift +++ b/bookstax/Services/KeychainService.swift @@ -26,7 +26,68 @@ actor KeychainService { try delete(key: tokenSecretKey) } - // MARK: - Synchronous static helper (for use at app init before async context) + // MARK: - Per-profile credential methods + + func saveCredentials(tokenId: String, tokenSecret: String, profileId: UUID) throws { + try save(value: tokenId, key: "tokenId-\(profileId.uuidString)") + try save(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)") + } + + func loadCredentials(profileId: UUID) throws -> (tokenId: String, tokenSecret: String)? { + guard let id = try load(key: "tokenId-\(profileId.uuidString)"), + let secret = try load(key: "tokenSecret-\(profileId.uuidString)") else { return nil } + return (id, secret) + } + + func deleteCredentials(profileId: UUID) throws { + try delete(key: "tokenId-\(profileId.uuidString)") + try delete(key: "tokenSecret-\(profileId.uuidString)") + } + + // MARK: - Synchronous static helpers (for use at app init / non-async contexts) + + static func loadCredentialsSync(profileId: UUID) -> (tokenId: String, tokenSecret: String)? { + guard let tokenId = loadSync(key: "tokenId-\(profileId.uuidString)"), + let tokenSecret = loadSync(key: "tokenSecret-\(profileId.uuidString)") else { return nil } + return (tokenId, tokenSecret) + } + + @discardableResult + static func saveSync(value: String, key: String) -> Bool { + let service = "com.bookstax.credentials" + guard let data = value.data(using: .utf8) else { return false } + let deleteQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key + ] + SecItemDelete(deleteQuery as CFDictionary) + let addQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecValueData: data + ] + return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess + } + + static func saveCredentialsSync(tokenId: String, tokenSecret: String, profileId: UUID) { + saveSync(value: tokenId, key: "tokenId-\(profileId.uuidString)") + saveSync(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)") + } + + static func deleteCredentialsSync(profileId: UUID) { + let service = "com.bookstax.credentials" + for suffix in ["tokenId-\(profileId.uuidString)", "tokenSecret-\(profileId.uuidString)"] { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: suffix + ] + SecItemDelete(query as CFDictionary) + } + } static func loadSync(key: String) -> String? { let service = "com.bookstax.credentials" diff --git a/bookstax/Services/SyncService.swift b/bookstax/Services/SyncService.swift index 222dc3c..f3c91e1 100644 --- a/bookstax/Services/SyncService.swift +++ b/bookstax/Services/SyncService.swift @@ -77,4 +77,15 @@ final class SyncService { async let booksTask: Void = syncBooks(context: context) _ = try await (shelvesTask, booksTask) } + + // MARK: - Clear all cached content + + func clearAllCache(context: ModelContext) throws { + try context.delete(model: CachedShelf.self) + try context.delete(model: CachedBook.self) + try context.delete(model: CachedPage.self) + try context.save() + UserDefaults.standard.removeObject(forKey: "lastSynced") + AppLog(.info, "Cleared all cached content", category: "SyncService") + } } diff --git a/bookstax/ViewModels/OnboardingViewModel.swift b/bookstax/ViewModels/OnboardingViewModel.swift index 07110c8..e70bb60 100644 --- a/bookstax/ViewModels/OnboardingViewModel.swift +++ b/bookstax/ViewModels/OnboardingViewModel.swift @@ -16,6 +16,7 @@ final class OnboardingViewModel { var navPath: NavigationPath = NavigationPath() // Input + var serverNameInput: String = "" var serverURLInput: String = "" var tokenIdInput: String = "" var tokenSecretInput: String = "" @@ -36,6 +37,10 @@ final class OnboardingViewModel { // Completion var isComplete: Bool = false + /// Set to true after successfully adding a server in the Settings "Add Server" flow. + var isAddComplete: Bool = false + /// When true, skips the ready step navigation (used in Add Server sheet). + var isAddServerMode: Bool = false // MARK: - Navigation @@ -117,6 +122,11 @@ final class OnboardingViewModel { let appName = info.appName ?? "BookStack" verifyPhase = .serverOK(appName: appName) + // Auto-populate server name from API if the user left it blank + if serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + serverNameInput = appName + } + // Configure the shared API client with validated credentials await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret) verifyPhase = .checkingToken @@ -124,25 +134,24 @@ final class OnboardingViewModel { // Attempt to fetch user info (non-fatal — some installs restrict /api/users) let userName = try? await BookStackAPI.shared.fetchCurrentUser().name - // Persist server URL and credentials - UserDefaults.standard.set(url, forKey: "serverURL") - do { - try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret) - } catch let error as BookStackError { - AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding") - verifyPhase = .failed(phase: "keychain", error: error) - return - } catch { - AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding") - verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription)) - return - } + // Create and persist a ServerProfile via the shared store + let profile = ServerProfile( + id: UUID(), + name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines), + serverURL: url + ) + ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret) AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding") verifyPhase = .done(appName: appName, userName: userName) - // Navigate to the ready step - navPath.append(Step.ready) + if isAddServerMode { + // In the "Add Server" sheet: signal completion so the sheet dismisses + isAddComplete = true + } else { + // Normal onboarding: navigate to the ready step + navPath.append(Step.ready) + } } // MARK: - Complete diff --git a/bookstax/Views/Onboarding/OnboardingView.swift b/bookstax/Views/Onboarding/OnboardingView.swift index 4d32344..fd4542c 100644 --- a/bookstax/Views/Onboarding/OnboardingView.swift +++ b/bookstax/Views/Onboarding/OnboardingView.swift @@ -195,6 +195,18 @@ struct ConnectStepView: View { .foregroundStyle(.secondary) } + // Server Name field + VStack(alignment: .leading, spacing: 6) { + Label(L("onboarding.server.name.label"), systemImage: "tag") + .font(.subheadline.bold()) + + TextField(L("onboarding.server.name.placeholder"), text: $viewModel.serverNameInput) + .autocorrectionDisabled() + .textInputAutocapitalization(.words) + .padding() + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } + // Server URL field VStack(alignment: .leading, spacing: 8) { HStack { @@ -204,6 +216,7 @@ struct ConnectStepView: View { .keyboardType(.URL) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .textContentType(.URL) .onChange(of: viewModel.serverURLInput) { if case .idle = viewModel.verifyPhase { } else { viewModel.resetVerification() @@ -253,6 +266,7 @@ struct ConnectStepView: View { } .autocorrectionDisabled() .textInputAutocapitalization(.never) + .textContentType(.username) .onChange(of: viewModel.tokenIdInput) { if case .idle = viewModel.verifyPhase { } else { viewModel.resetVerification() @@ -293,6 +307,7 @@ struct ConnectStepView: View { } .autocorrectionDisabled() .textInputAutocapitalization(.never) + .textContentType(.password) .onChange(of: viewModel.tokenSecretInput) { if case .idle = viewModel.verifyPhase { } else { viewModel.resetVerification() @@ -350,6 +365,9 @@ struct ConnectStepView: View { .onDisappear { verifyTask?.cancel() } + .onChange(of: viewModel.serverURLInput) { _, _ in + // Clear name hint when URL changes so it re-auto-fills on next verify + } } private var canConnect: Bool { diff --git a/bookstax/Views/Settings/AddServerView.swift b/bookstax/Views/Settings/AddServerView.swift new file mode 100644 index 0000000..c789c4e --- /dev/null +++ b/bookstax/Views/Settings/AddServerView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Sheet that lets the user connect an additional BookStack server. +/// Reuses ConnectStepView — skips the language/welcome steps. +struct AddServerView: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: OnboardingViewModel = { + let vm = OnboardingViewModel() + vm.isAddServerMode = true + return vm + }() + + var body: some View { + NavigationStack { + ConnectStepView(viewModel: viewModel) + .navigationTitle(L("settings.servers.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L("create.cancel")) { dismiss() } + } + } + } + .onChange(of: viewModel.isAddComplete) { _, done in + if done { dismiss() } + } + } +} diff --git a/bookstax/Views/Settings/EditServerView.swift b/bookstax/Views/Settings/EditServerView.swift new file mode 100644 index 0000000..9cf6769 --- /dev/null +++ b/bookstax/Views/Settings/EditServerView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct EditServerView: View { + @Environment(\.dismiss) private var dismiss + @Environment(ServerProfileStore.self) private var profileStore + + let profile: ServerProfile + + @State private var name: String + @State private var serverURL: String + @State private var tokenId: String = "" + @State private var tokenSecret: String = "" + @State private var showTokenId = false + @State private var showTokenSecret = false + @State private var changeCredentials = false + @State private var isSaving = false + @State private var saveError: String? = nil + + init(profile: ServerProfile) { + self.profile = profile + _name = State(initialValue: profile.name) + _serverURL = State(initialValue: profile.serverURL) + } + + var body: some View { + NavigationStack { + Form { + // Name & URL + Section(L("onboarding.server.name.label")) { + TextField(L("onboarding.server.name.placeholder"), text: $name) + .autocorrectionDisabled() + .textInputAutocapitalization(.words) + } + + Section(L("onboarding.server.title")) { + TextField(L("onboarding.server.placeholder"), text: $serverURL) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .textContentType(.URL) + } + + // Optional credential rotation + Section { + Toggle(L("settings.servers.edit.changecreds"), isOn: $changeCredentials.animation()) + } footer: { + Text(L("settings.servers.edit.changecreds.footer")) + .font(.footnote) + } + + if changeCredentials { + Section(L("onboarding.token.id.label")) { + HStack { + Group { + if showTokenId { + TextField(L("onboarding.token.id.label"), text: $tokenId) + } else { + SecureField(L("onboarding.token.id.label"), text: $tokenId) + } + } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .textContentType(.username) + + Button { showTokenId.toggle() } label: { + Image(systemName: showTokenId ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + Section(L("onboarding.token.secret.label")) { + HStack { + Group { + if showTokenSecret { + TextField(L("onboarding.token.secret.label"), text: $tokenSecret) + } else { + SecureField(L("onboarding.token.secret.label"), text: $tokenSecret) + } + } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .textContentType(.password) + + Button { showTokenSecret.toggle() } label: { + Image(systemName: showTokenSecret ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + + if let error = saveError { + Section { + Label(error, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.red) + .font(.footnote) + } + } + } + .navigationTitle(L("settings.servers.edit.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L("create.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(L("common.done")) { + save() + } + .disabled(!canSave || isSaving) + .overlay { if isSaving { ProgressView().scaleEffect(0.7) } } + } + } + } + } + + // MARK: - Helpers + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (!changeCredentials || (!tokenId.isEmpty && !tokenSecret.isEmpty)) + } + + private func save() { + isSaving = true + saveError = nil + + var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") { + cleanURL = "https://" + cleanURL + } + while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) } + + profileStore.updateProfile( + profile, + newName: name.trimmingCharacters(in: .whitespacesAndNewlines), + newURL: cleanURL, + newTokenId: changeCredentials ? tokenId : nil, + newTokenSecret: changeCredentials ? tokenSecret : nil + ) + + isSaving = false + dismiss() + } +} diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift index dcd57f6..82b0789 100644 --- a/bookstax/Views/Settings/SettingsView.swift +++ b/bookstax/Views/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import SwiftUI import SafariServices +import SwiftData struct SettingsView: View { @AppStorage("onboardingComplete") private var onboardingComplete = false @@ -8,11 +9,11 @@ struct SettingsView: View { @AppStorage("appTheme") private var appTheme = "system" @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue @AppStorage("loggingEnabled") private var loggingEnabled = false + @Environment(ServerProfileStore.self) private var profileStore private var selectedTheme: AccentTheme { AccentTheme(rawValue: accentThemeRaw) ?? .ocean } - @State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" @State private var showSignOutAlert = false @State private var isSyncing = false @State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date @@ -20,6 +21,11 @@ struct SettingsView: View { @State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current @State private var showLogViewer = false @State private var shareItems: [Any]? = nil + @Environment(\.modelContext) private var modelContext + @State private var showAddServer = false + @State private var profileToSwitch: ServerProfile? = nil + @State private var profileToDelete: ServerProfile? = nil + @State private var profileToEdit: ServerProfile? = nil private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" @@ -93,33 +99,56 @@ struct SettingsView: View { .padding(.vertical, 4) } - // Account section - Section(L("settings.account")) { - HStack { - Image(systemName: "person.circle.fill") - .font(.title) - .foregroundStyle(.blue) - VStack(alignment: .leading) { - Text(L("settings.account.connected")) - .font(.headline) - Text(serverURL) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) + // Servers section + Section(L("settings.servers")) { + ForEach(profileStore.profiles) { profile in + Button { + if profile.id != profileStore.activeProfileId { + profileToSwitch = profile + } + } label: { + HStack(spacing: 12) { + Image(systemName: profile.id == profileStore.activeProfileId + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(profile.id == profileStore.activeProfileId + ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(profile.name) + .font(.body) + .foregroundStyle(.primary) + Text(profile.serverURL) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if profile.id == profileStore.activeProfileId { + Text(L("settings.servers.active")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + profileToDelete = profile + } label: { + Label(L("settings.servers.delete.confirm"), systemImage: "trash") + } + .tint(.red) + Button { + profileToEdit = profile + } label: { + Label(L("settings.servers.edit"), systemImage: "pencil") + } + .tint(.blue) } } - .padding(.vertical, 4) Button { - UIPasteboard.general.string = serverURL + showAddServer = true } label: { - Label(L("settings.account.copyurl"), systemImage: "doc.on.doc") - } - - Button(role: .destructive) { - showSignOutAlert = true - } label: { - Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right") + Label(L("settings.servers.add"), systemImage: "plus.circle") } } @@ -207,11 +236,58 @@ struct SettingsView: View { .onAppear { loggingEnabled = LogManager.shared.isEnabled } - .alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) { - Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() } - Button(L("settings.signout.alert.cancel"), role: .cancel) {} + // Switch server confirmation + .alert(L("settings.servers.switch.title"), isPresented: Binding( + get: { profileToSwitch != nil }, + set: { if !$0 { profileToSwitch = nil } } + )) { + Button(L("settings.servers.switch.confirm")) { + if let p = profileToSwitch { profileStore.activate(p) } + profileToSwitch = nil + } + Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToSwitch = nil } } message: { - Text(L("settings.signout.alert.message")) + if let p = profileToSwitch { + Text(String(format: L("settings.servers.switch.message"), p.name)) + } + } + // Delete inactive server confirmation + .alert(L("settings.servers.delete.title"), isPresented: Binding( + get: { profileToDelete != nil && profileToDelete?.id != profileStore.activeProfileId }, + set: { if !$0 { profileToDelete = nil } } + )) { + Button(L("settings.servers.delete.confirm"), role: .destructive) { + if let p = profileToDelete { removeProfile(p) } + profileToDelete = nil + } + Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil } + } message: { + if let p = profileToDelete { + Text(String(format: L("settings.servers.delete.message"), p.name)) + } + } + // Delete ACTIVE server — stronger warning + .alert(L("settings.servers.delete.active.title"), isPresented: Binding( + get: { profileToDelete != nil && profileToDelete?.id == profileStore.activeProfileId }, + set: { if !$0 { profileToDelete = nil } } + )) { + Button(L("settings.servers.delete.confirm"), role: .destructive) { + if let p = profileToDelete { removeProfile(p) } + profileToDelete = nil + } + Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil } + } message: { + if let p = profileToDelete { + Text(String(format: L("settings.servers.delete.active.message"), p.name)) + } + } + // Add server sheet + .sheet(isPresented: $showAddServer) { + AddServerView() + } + // Edit server sheet + .sheet(item: $profileToEdit) { profile in + EditServerView(profile: profile) } .sheet(item: $showSafari) { url in SafariView(url: url) @@ -233,11 +309,13 @@ struct SettingsView: View { // MARK: - Actions - private func signOut() { - Task { - try? await KeychainService.shared.deleteCredentials() - UserDefaults.standard.removeObject(forKey: "serverURL") - UserDefaults.standard.removeObject(forKey: "lastSynced") + private func removeProfile(_ profile: ServerProfile) { + profileStore.remove(profile) + // Always clear the cache — it may contain content from this server + try? SyncService.shared.clearAllCache(context: modelContext) + lastSynced = nil + // If no profiles remain, return to onboarding + if profileStore.profiles.isEmpty { onboardingComplete = false } } diff --git a/bookstax/bookstaxApp.swift b/bookstax/bookstaxApp.swift index e2ec16e..ce2b300 100644 --- a/bookstax/bookstaxApp.swift +++ b/bookstax/bookstaxApp.swift @@ -14,6 +14,9 @@ struct bookstaxApp: App { @AppStorage("appTheme") private var appTheme = "system" @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue + // ServerProfileStore is initialised here so migration runs at launch + private var profileStore = ServerProfileStore.shared + private var preferredColorScheme: ColorScheme? { switch appTheme { case "light": return .light @@ -43,13 +46,16 @@ struct bookstaxApp: App { var body: some Scene { WindowGroup { Group { - if onboardingComplete { + if onboardingComplete && profileStore.activeProfile != nil { MainTabView() .environment(ConnectivityMonitor.shared) + // Re-creates the entire tab hierarchy when the active server changes + .id(profileStore.activeProfileId) } else { OnboardingView() } } + .environment(profileStore) .environment(\.accentTheme, accentTheme) .tint(accentTheme.accentColor) .preferredColorScheme(preferredColorScheme) diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings index f151630..29cddd8 100644 --- a/bookstax/de.lproj/Localizable.strings +++ b/bookstax/de.lproj/Localizable.strings @@ -214,6 +214,25 @@ "search.filter.tag" = "Tag"; "search.filter.tag.clear" = "Tag-Filter entfernen"; +// MARK: - Servers +"settings.servers" = "Server"; +"settings.servers.add" = "Server hinzufügen…"; +"settings.servers.active" = "Aktiv"; +"settings.servers.switch.title" = "Server wechseln"; +"settings.servers.switch.message" = "Zu \"%@\" wechseln? Die App wird neu geladen."; +"settings.servers.switch.confirm" = "Wechseln"; +"settings.servers.delete.title" = "Server entfernen"; +"settings.servers.delete.message" = "\"%@\" entfernen? Der Cache wird geleert. Dies kann nicht rückgängig gemacht werden."; +"settings.servers.delete.confirm" = "Entfernen"; +"settings.servers.delete.active.title" = "Aktiven Server entfernen?"; +"settings.servers.delete.active.message" = "\"%@\" ist dein aktueller Server. Durch das Entfernen werden alle zwischengespeicherten Inhalte gelöscht und du wirst von diesem Server abgemeldet."; +"settings.servers.edit" = "Bearbeiten"; +"settings.servers.edit.title" = "Server bearbeiten"; +"settings.servers.edit.changecreds" = "API-Token aktualisieren"; +"settings.servers.edit.changecreds.footer" = "Aktivieren, um Token-ID und Secret für diesen Server zu ersetzen."; +"onboarding.server.name.label" = "Servername"; +"onboarding.server.name.placeholder" = "z.B. Firmen-Wiki"; + // MARK: - Common "common.ok" = "OK"; "common.error" = "Unbekannter Fehler"; diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings index 5d62649..0c38506 100644 --- a/bookstax/en.lproj/Localizable.strings +++ b/bookstax/en.lproj/Localizable.strings @@ -214,6 +214,25 @@ "search.filter.tag" = "Tag"; "search.filter.tag.clear" = "Clear Tag Filter"; +// MARK: - Servers +"settings.servers" = "Servers"; +"settings.servers.add" = "Add Server…"; +"settings.servers.active" = "Active"; +"settings.servers.switch.title" = "Switch Server"; +"settings.servers.switch.message" = "Switch to \"%@\"? The app will reload."; +"settings.servers.switch.confirm" = "Switch"; +"settings.servers.delete.title" = "Remove Server"; +"settings.servers.delete.message" = "Remove \"%@\"? Its cached content will be cleared. This cannot be undone."; +"settings.servers.delete.confirm" = "Remove"; +"settings.servers.delete.active.title" = "Remove Active Server?"; +"settings.servers.delete.active.message" = "\"%@\" is your current server. Removing it will clear all cached content and sign you out of this server."; +"settings.servers.edit" = "Edit"; +"settings.servers.edit.title" = "Edit Server"; +"settings.servers.edit.changecreds" = "Update API Token"; +"settings.servers.edit.changecreds.footer" = "Enable to replace the stored Token ID and Secret for this server."; +"onboarding.server.name.label" = "Server Name"; +"onboarding.server.name.placeholder" = "e.g. Work Wiki"; + // MARK: - Common "common.ok" = "OK"; "common.error" = "Unknown error"; diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings index e7bb684..dd14a46 100644 --- a/bookstax/es.lproj/Localizable.strings +++ b/bookstax/es.lproj/Localizable.strings @@ -214,6 +214,25 @@ "search.filter.tag" = "Etiqueta"; "search.filter.tag.clear" = "Eliminar filtro de etiqueta"; +// MARK: - Servers +"settings.servers" = "Servidores"; +"settings.servers.add" = "Añadir servidor…"; +"settings.servers.active" = "Activo"; +"settings.servers.switch.title" = "Cambiar servidor"; +"settings.servers.switch.message" = "¿Cambiar a \"%@\"? La app se recargará."; +"settings.servers.switch.confirm" = "Cambiar"; +"settings.servers.delete.title" = "Eliminar servidor"; +"settings.servers.delete.message" = "¿Eliminar \"%@\"? Se borrará el contenido en caché. Esta acción no se puede deshacer."; +"settings.servers.delete.confirm" = "Eliminar"; +"settings.servers.delete.active.title" = "¿Eliminar el servidor activo?"; +"settings.servers.delete.active.message" = "\"%@\" es tu servidor actual. Al eliminarlo se borrará todo el contenido en caché y se cerrará sesión en este servidor."; +"settings.servers.edit" = "Editar"; +"settings.servers.edit.title" = "Editar servidor"; +"settings.servers.edit.changecreds" = "Actualizar token API"; +"settings.servers.edit.changecreds.footer" = "Activa para reemplazar el Token ID y Secret almacenados para este servidor."; +"onboarding.server.name.label" = "Nombre del servidor"; +"onboarding.server.name.placeholder" = "p.ej. Wiki de trabajo"; + // MARK: - Common "common.ok" = "Aceptar"; "common.error" = "Error desconocido";