Auf ausdruecklichen Wunsch jetzt auch die internen Bezeichner, weil die Installation noch keine echten Daten enthaelt: POSTGRES_USER/PASSWORD/DB, der Volume-Name, die Standard-DATABASE_URL, der localStorage-Schluessel und die iOS-Zeichenketten inklusive Keychain-Konto. Das setzt eine leere Datenbank voraus: Der neue Volume-Name legt eine neue, leere Datenbank an. Die alte bleibt als verwaistes Volume liegen und muss von Hand entfernt werden. Die Warnung in docker-compose.yml bleibt stehen, damit eine spaetere Umbenennung nicht versehentlich mit Daten im Volume passiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.2 KiB
Swift
115 lines
4.2 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
|
/// die Server-URL in den UserDefaults (nicht geheim).
|
|
final class Session: ObservableObject {
|
|
static let shared = Session()
|
|
|
|
private let urlKey = "server_url"
|
|
private let usernameKey = "username"
|
|
private let stayKey = "stay_logged_in"
|
|
private let keychainAccount = "vorrania-token"
|
|
|
|
@Published private(set) var baseURL: URL?
|
|
@Published private(set) var token: String?
|
|
@Published var username: String = ""
|
|
@Published var isAdmin: Bool = false
|
|
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
|
@Published private(set) var stayLoggedIn: Bool = true
|
|
|
|
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
|
|
|
private init() {
|
|
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
|
baseURL = Session.normalize(stored)
|
|
}
|
|
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
|
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
|
token = Keychain.read(account: keychainAccount)
|
|
username = UserDefaults.standard.string(forKey: usernameKey) ?? ""
|
|
}
|
|
|
|
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
|
/// Pfade ("api/...") korrekt aufgelöst werden.
|
|
static func normalize(_ raw: String) -> URL? {
|
|
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { return nil }
|
|
if !text.contains("://") { text = "http://" + text }
|
|
if !text.hasSuffix("/") { text += "/" }
|
|
return URL(string: text)
|
|
}
|
|
|
|
func setServer(_ raw: String) {
|
|
guard let url = Session.normalize(raw) else { return }
|
|
baseURL = url
|
|
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
|
}
|
|
|
|
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
|
/// Auf dem Geraet bleibt dann nichts zurueck.
|
|
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
|
token = newToken
|
|
username = name
|
|
isAdmin = admin
|
|
stayLoggedIn = persist
|
|
UserDefaults.standard.set(persist, forKey: stayKey)
|
|
|
|
if persist {
|
|
Keychain.write(newToken, account: keychainAccount)
|
|
UserDefaults.standard.set(name, forKey: usernameKey)
|
|
} else {
|
|
Keychain.delete(account: keychainAccount)
|
|
UserDefaults.standard.removeObject(forKey: usernameKey)
|
|
}
|
|
}
|
|
|
|
func logout() {
|
|
token = nil
|
|
username = ""
|
|
isAdmin = false
|
|
Keychain.delete(account: keychainAccount)
|
|
UserDefaults.standard.removeObject(forKey: usernameKey)
|
|
}
|
|
}
|
|
|
|
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
|
enum Keychain {
|
|
private static let service = "com.scarriffle.projectgood"
|
|
|
|
static func write(_ value: String, account: String) {
|
|
delete(account: account)
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecValueData as String: Data(value.utf8),
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
]
|
|
SecItemAdd(query as CFDictionary, nil)
|
|
}
|
|
|
|
static func read(account: String) -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
static func delete(account: String) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|