Multi-Server implementiert
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,68 @@ actor KeychainService {
|
|||||||
try delete(key: tokenSecretKey)
|
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? {
|
static func loadSync(key: String) -> String? {
|
||||||
let service = "com.bookstax.credentials"
|
let service = "com.bookstax.credentials"
|
||||||
|
|||||||
@@ -77,4 +77,15 @@ final class SyncService {
|
|||||||
async let booksTask: Void = syncBooks(context: context)
|
async let booksTask: Void = syncBooks(context: context)
|
||||||
_ = try await (shelvesTask, booksTask)
|
_ = 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class OnboardingViewModel {
|
|||||||
var navPath: NavigationPath = NavigationPath()
|
var navPath: NavigationPath = NavigationPath()
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
|
var serverNameInput: String = ""
|
||||||
var serverURLInput: String = ""
|
var serverURLInput: String = ""
|
||||||
var tokenIdInput: String = ""
|
var tokenIdInput: String = ""
|
||||||
var tokenSecretInput: String = ""
|
var tokenSecretInput: String = ""
|
||||||
@@ -36,6 +37,10 @@ final class OnboardingViewModel {
|
|||||||
|
|
||||||
// Completion
|
// Completion
|
||||||
var isComplete: Bool = false
|
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
|
// MARK: - Navigation
|
||||||
|
|
||||||
@@ -117,6 +122,11 @@ final class OnboardingViewModel {
|
|||||||
let appName = info.appName ?? "BookStack"
|
let appName = info.appName ?? "BookStack"
|
||||||
verifyPhase = .serverOK(appName: appName)
|
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
|
// Configure the shared API client with validated credentials
|
||||||
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
|
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||||
verifyPhase = .checkingToken
|
verifyPhase = .checkingToken
|
||||||
@@ -124,26 +134,25 @@ final class OnboardingViewModel {
|
|||||||
// Attempt to fetch user info (non-fatal — some installs restrict /api/users)
|
// Attempt to fetch user info (non-fatal — some installs restrict /api/users)
|
||||||
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
|
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
|
||||||
|
|
||||||
// Persist server URL and credentials
|
// Create and persist a ServerProfile via the shared store
|
||||||
UserDefaults.standard.set(url, forKey: "serverURL")
|
let profile = ServerProfile(
|
||||||
do {
|
id: UUID(),
|
||||||
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
|
name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
} catch let error as BookStackError {
|
serverURL: url
|
||||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
)
|
||||||
verifyPhase = .failed(phase: "keychain", error: error)
|
ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
|
||||||
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
|
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
|
||||||
verifyPhase = .done(appName: appName, userName: userName)
|
verifyPhase = .done(appName: appName, userName: userName)
|
||||||
|
|
||||||
// Navigate to the ready step
|
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)
|
navPath.append(Step.ready)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Complete
|
// MARK: - Complete
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ struct ConnectStepView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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
|
// Server URL field
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -204,6 +216,7 @@ struct ConnectStepView: View {
|
|||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.textContentType(.URL)
|
||||||
.onChange(of: viewModel.serverURLInput) {
|
.onChange(of: viewModel.serverURLInput) {
|
||||||
if case .idle = viewModel.verifyPhase { } else {
|
if case .idle = viewModel.verifyPhase { } else {
|
||||||
viewModel.resetVerification()
|
viewModel.resetVerification()
|
||||||
@@ -253,6 +266,7 @@ struct ConnectStepView: View {
|
|||||||
}
|
}
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.textContentType(.username)
|
||||||
.onChange(of: viewModel.tokenIdInput) {
|
.onChange(of: viewModel.tokenIdInput) {
|
||||||
if case .idle = viewModel.verifyPhase { } else {
|
if case .idle = viewModel.verifyPhase { } else {
|
||||||
viewModel.resetVerification()
|
viewModel.resetVerification()
|
||||||
@@ -293,6 +307,7 @@ struct ConnectStepView: View {
|
|||||||
}
|
}
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.textContentType(.password)
|
||||||
.onChange(of: viewModel.tokenSecretInput) {
|
.onChange(of: viewModel.tokenSecretInput) {
|
||||||
if case .idle = viewModel.verifyPhase { } else {
|
if case .idle = viewModel.verifyPhase { } else {
|
||||||
viewModel.resetVerification()
|
viewModel.resetVerification()
|
||||||
@@ -350,6 +365,9 @@ struct ConnectStepView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
verifyTask?.cancel()
|
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 {
|
private var canConnect: Bool {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||||
@@ -8,11 +9,11 @@ struct SettingsView: View {
|
|||||||
@AppStorage("appTheme") private var appTheme = "system"
|
@AppStorage("appTheme") private var appTheme = "system"
|
||||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
||||||
@AppStorage("loggingEnabled") private var loggingEnabled = false
|
@AppStorage("loggingEnabled") private var loggingEnabled = false
|
||||||
|
@Environment(ServerProfileStore.self) private var profileStore
|
||||||
|
|
||||||
private var selectedTheme: AccentTheme {
|
private var selectedTheme: AccentTheme {
|
||||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||||
}
|
}
|
||||||
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
|
||||||
@State private var showSignOutAlert = false
|
@State private var showSignOutAlert = false
|
||||||
@State private var isSyncing = false
|
@State private var isSyncing = false
|
||||||
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
|
@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 selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
|
||||||
@State private var showLogViewer = false
|
@State private var showLogViewer = false
|
||||||
@State private var shareItems: [Any]? = nil
|
@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 appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||||
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||||
@@ -93,33 +99,56 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account section
|
// Servers section
|
||||||
Section(L("settings.account")) {
|
Section(L("settings.servers")) {
|
||||||
HStack {
|
ForEach(profileStore.profiles) { profile in
|
||||||
Image(systemName: "person.circle.fill")
|
Button {
|
||||||
.font(.title)
|
if profile.id != profileStore.activeProfileId {
|
||||||
.foregroundStyle(.blue)
|
profileToSwitch = profile
|
||||||
VStack(alignment: .leading) {
|
}
|
||||||
Text(L("settings.account.connected"))
|
} label: {
|
||||||
.font(.headline)
|
HStack(spacing: 12) {
|
||||||
Text(serverURL)
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.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 {
|
Button {
|
||||||
UIPasteboard.general.string = serverURL
|
showAddServer = true
|
||||||
} label: {
|
} label: {
|
||||||
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
|
Label(L("settings.servers.add"), systemImage: "plus.circle")
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
showSignOutAlert = true
|
|
||||||
} label: {
|
|
||||||
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +236,58 @@ struct SettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loggingEnabled = LogManager.shared.isEnabled
|
loggingEnabled = LogManager.shared.isEnabled
|
||||||
}
|
}
|
||||||
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
|
// Switch server confirmation
|
||||||
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
|
.alert(L("settings.servers.switch.title"), isPresented: Binding(
|
||||||
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
|
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: {
|
} 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
|
.sheet(item: $showSafari) { url in
|
||||||
SafariView(url: url)
|
SafariView(url: url)
|
||||||
@@ -233,11 +309,13 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func signOut() {
|
private func removeProfile(_ profile: ServerProfile) {
|
||||||
Task {
|
profileStore.remove(profile)
|
||||||
try? await KeychainService.shared.deleteCredentials()
|
// Always clear the cache — it may contain content from this server
|
||||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
try? SyncService.shared.clearAllCache(context: modelContext)
|
||||||
UserDefaults.standard.removeObject(forKey: "lastSynced")
|
lastSynced = nil
|
||||||
|
// If no profiles remain, return to onboarding
|
||||||
|
if profileStore.profiles.isEmpty {
|
||||||
onboardingComplete = false
|
onboardingComplete = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ struct bookstaxApp: App {
|
|||||||
@AppStorage("appTheme") private var appTheme = "system"
|
@AppStorage("appTheme") private var appTheme = "system"
|
||||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
@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? {
|
private var preferredColorScheme: ColorScheme? {
|
||||||
switch appTheme {
|
switch appTheme {
|
||||||
case "light": return .light
|
case "light": return .light
|
||||||
@@ -43,13 +46,16 @@ struct bookstaxApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
if onboardingComplete {
|
if onboardingComplete && profileStore.activeProfile != nil {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
.environment(ConnectivityMonitor.shared)
|
.environment(ConnectivityMonitor.shared)
|
||||||
|
// Re-creates the entire tab hierarchy when the active server changes
|
||||||
|
.id(profileStore.activeProfileId)
|
||||||
} else {
|
} else {
|
||||||
OnboardingView()
|
OnboardingView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.environment(profileStore)
|
||||||
.environment(\.accentTheme, accentTheme)
|
.environment(\.accentTheme, accentTheme)
|
||||||
.tint(accentTheme.accentColor)
|
.tint(accentTheme.accentColor)
|
||||||
.preferredColorScheme(preferredColorScheme)
|
.preferredColorScheme(preferredColorScheme)
|
||||||
|
|||||||
@@ -214,6 +214,25 @@
|
|||||||
"search.filter.tag" = "Tag";
|
"search.filter.tag" = "Tag";
|
||||||
"search.filter.tag.clear" = "Tag-Filter entfernen";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.error" = "Unbekannter Fehler";
|
"common.error" = "Unbekannter Fehler";
|
||||||
|
|||||||
@@ -214,6 +214,25 @@
|
|||||||
"search.filter.tag" = "Tag";
|
"search.filter.tag" = "Tag";
|
||||||
"search.filter.tag.clear" = "Clear Tag Filter";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.error" = "Unknown error";
|
"common.error" = "Unknown error";
|
||||||
|
|||||||
@@ -214,6 +214,25 @@
|
|||||||
"search.filter.tag" = "Etiqueta";
|
"search.filter.tag" = "Etiqueta";
|
||||||
"search.filter.tag.clear" = "Eliminar filtro de 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
|
// MARK: - Common
|
||||||
"common.ok" = "Aceptar";
|
"common.ok" = "Aceptar";
|
||||||
"common.error" = "Error desconocido";
|
"common.error" = "Error desconocido";
|
||||||
|
|||||||
Reference in New Issue
Block a user