// // AdminSession.swift // FTStore Admin // // The authenticated half of the app. Owns the FTStore Admin session token // and is the only door every screen goes through, so there is exactly // one place that decides "are we allowed to talk to the server". // // What the app ships: a public base URL. What it does not ship: any // credential, any API key, any token. FTStore Admin is dark until someone signs // in with a real FTStore Admin account, and the server re-checks that session // against its database on every single request — so pulling this IPA apart // yields a login form and nothing else. // import Foundation import Combine import LocalAuthentication @MainActor final class AdminSession: ObservableObject { static let shared = AdminSession() private static let _cachedUserKey = "admin.cachedUser" private static let _biometricsKey = "admin.requireBiometrics" /// Restoring a stored session at launch — distinct from "signed out" so the /// login form doesn't flash on every cold start. @Published private(set) var isRestoring = true @Published private(set) var user: AdminUser? /// Set when a stored session is present but the device owner hasn't passed /// the local biometric gate yet. @Published private(set) var isLocked = false /// Populated when the last `/api/auth/me` couldn't reach the server; the /// cached identity is still shown, flagged as unverified. @Published private(set) var connectionWarning: String? /// Optional second gate in front of FTStore Admin. It protects an unlocked, /// unattended device — the server credentials are what actually protect the /// data — so it is opt-in and never blocks the install flow. @Published var requireBiometrics: Bool { didSet { guard oldValue != requireBiometrics else { return } UserDefaults.standard.set(requireBiometrics, forKey: Self._biometricsKey) } } /// Last username, so the login form can prefill it. Not a secret. @Published var lastUsername: String { didSet { UserDefaults.standard.set(lastUsername, forKey: "admin.lastUsername") } } private let _api = AdminAPI.shared private init() { let defaults = UserDefaults.standard requireBiometrics = defaults.bool(forKey: Self._biometricsKey) lastUsername = defaults.string(forKey: "admin.lastUsername") ?? "" } // MARK: - State var isSignedIn: Bool { user != nil && !isLocked } var isAdmin: Bool { user?.isAdmin ?? false } /// The token, read fresh from the Keychain on every request rather than /// cached in memory for the lifetime of the process. private var _token: String? { AdminKeychain.get(AdminKeychain.sessionAccount) } var hasStoredSession: Bool { _token != nil } /// Host FTStore Admin talks to — shown on the login screen so it's obvious /// which server is being authenticated against. var serverDescription: String { URL(string: AdminConfig.baseURL)?.host ?? AdminConfig.baseURL } // MARK: - Lifecycle /// Validate a stored session at launch. A 401 means the operator revoked it /// (or it expired) — drop it. A network failure keeps it: being on a train /// is not a reason to make someone type their password again. func restore() async { defer { isRestoring = false } guard let token = _token else { user = nil isLocked = false return } user = _cachedUser() isLocked = requireBiometrics do { let fresh = try await _api.me(token: token) user = fresh _cacheUser(fresh) connectionWarning = nil } catch AdminAPIError.unauthorized { await _clearLocalSession() } catch let error as AdminAPIError { // Keep the cached identity, but say so. connectionWarning = error.errorDescription } catch { connectionWarning = error.localizedDescription } } func signIn(username: String, password: String) async throws { let result = try await _api.signIn(username: username, password: password) guard AdminKeychain.set(result.token, for: AdminKeychain.sessionAccount) else { throw AdminAPIError.network(.localized("Couldn't store the session securely.")) } user = result.user lastUsername = result.user.username isLocked = false connectionWarning = nil _cacheUser(result.user) } /// Ends the session on the server too, so the token is dead even if it were /// somehow lifted off the device afterwards. func signOut() async { let token = _token await _clearLocalSession() if let token { await _api.signOut(token: token) } } private func _clearLocalSession() async { AdminKeychain.remove(AdminKeychain.sessionAccount) UserDefaults.standard.removeObject(forKey: Self._cachedUserKey) user = nil isLocked = false connectionWarning = nil } // MARK: - Local lock func lock() { guard user != nil else { return } isLocked = true } /// Face ID / Touch ID, falling back to the device passcode so a failed /// biometric can't strand someone out of their own FTStore Admin. func unlock() async -> String? { let context = LAContext() context.localizedFallbackTitle = .localized("Use Passcode") guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) else { // No biometrics and no passcode set — the gate can't be enforced, so // don't let it become a lockout. isLocked = false return nil } do { let ok = try await context.evaluatePolicy( .deviceOwnerAuthentication, localizedReason: .localized("Unlock FTStore Admin") ) if ok { isLocked = false } return nil } catch { return error.localizedDescription } } // MARK: - Cached identity private func _cacheUser(_ user: AdminUser) { guard let data = try? JSONEncoder().encode(user) else { return } UserDefaults.standard.set(data, forKey: Self._cachedUserKey) } private func _cachedUser() -> AdminUser? { guard let data = UserDefaults.standard.data(forKey: Self._cachedUserKey) else { return nil } return try? JSONDecoder().decode(AdminUser.self, from: data) } // MARK: - Authenticated requests // // Every FTStore Admin screen calls through here. A 401 anywhere means the session // is gone, so it tears the whole FTStore Admin down in one place instead of each // view inventing its own recovery. func get( _ path: String, query: [String: String] = [:], as type: Response.Type ) async throws -> Response { try await _guard { token in try await self._api.get(path, query: query, as: type, token: token) } } func send( _ method: AdminAPI.Method, _ path: String, query: [String: String] = [:], as type: Response.Type ) async throws -> Response { try await _guard { token in try await self._api.send(method, path, query: query, as: type, token: token) } } func send( _ method: AdminAPI.Method, _ path: String, query: [String: String] = [:], body: Body, as type: Response.Type ) async throws -> Response { try await _guard { token in try await self._api.send(method, path, query: query, body: body, as: type, token: token) } } /// Raw bytes in and out — the settings backup, which is a document rather /// than a model and must round-trip byte-for-byte. func rawData( _ method: AdminAPI.Method, _ path: String, query: [String: String] = [:], body: Data? = nil, contentType: String? = nil ) async throws -> Data { try await _guard { token in try await self._api.rawData( method, path, query: query, body: body, contentType: contentType, token: token ) } } func upload( _ path: String, fields: [String: String], fileField: String, fileName: String, mimeType: String, fileData: Data, as type: Response.Type ) async throws -> Response { try await _guard { token in try await self._api.upload( path, fields: fields, fileField: fileField, fileName: fileName, mimeType: mimeType, fileData: fileData, as: type, token: token ) } } /// Convenience for the action endpoints, which all answer the same ack. @discardableResult func act(_ method: AdminAPI.Method, _ path: String, query: [String: String] = [:]) async throws -> AdminAck { try await send(method, path, query: query, as: AdminAck.self) } @discardableResult func act(_ method: AdminAPI.Method, _ path: String, body: Body) async throws -> AdminAck { try await send(method, path, body: body, as: AdminAck.self) } private func _guard( _ operation: (String) async throws -> Response ) async throws -> Response { guard let token = _token else { await _clearLocalSession() throw AdminAPIError.unauthorized } do { return try await operation(token) } catch AdminAPIError.unauthorized { await _clearLocalSession() throw AdminAPIError.unauthorized } } }