add keychain credential storage, persist account config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:25:49 +01:00
parent 74179cb534
commit 1b8f3b6665
2 changed files with 95 additions and 1 deletions

View File

@@ -45,6 +45,10 @@ struct ContentView: View {
guard let (config, credentials) = accountSetup.buildConfig() else { return }
do {
try viewModel.setup(config: config, credentials: credentials)
try KeychainService.saveCredentials(credentials, for: config.id)
if let data = try? JSONEncoder().encode(config) {
UserDefaults.standard.set(data, forKey: "accountConfig")
}
Task {
await viewModel.syncNow()
await viewModel.loadMailboxes(accountId: config.id)
@@ -57,6 +61,20 @@ struct ContentView: View {
}
private func loadExistingAccount() {
// Keychain loading added in Task 15
guard let data = UserDefaults.standard.data(forKey: "accountConfig"),
let config = try? JSONDecoder().decode(AccountConfig.self, from: data),
let credentials = try? KeychainService.loadCredentials(for: config.id)
else { return }
do {
try viewModel.setup(config: config, credentials: credentials)
Task {
await viewModel.loadMailboxes(accountId: config.id)
viewModel.startObservingThreads(accountId: config.id)
await viewModel.syncNow()
viewModel.startPeriodicSync()
}
} catch {
// Account config exists but setup failed show account setup
}
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
import Security
import Models
enum KeychainService {
private static let service = "de.felixfoertsch.MagnumOpus"
static func saveCredentials(_ credentials: Credentials, for accountId: String) throws {
let passwordData = Data(credentials.password.utf8)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
]
SecItemDelete(deleteQuery as CFDictionary)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
kSecAttrLabel as String: credentials.username,
kSecValueData as String: passwordData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func loadCredentials(for accountId: String) throws -> Credentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
kSecReturnData as String: true,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let attrs = result as? [String: Any],
let data = attrs[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8),
let username = attrs[kSecAttrLabel as String] as? String
else {
if status == errSecItemNotFound { return nil }
throw KeychainError.loadFailed(status)
}
return Credentials(username: username, password: password)
}
static func deleteCredentials(for accountId: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
}