Files
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

293 lines
8.6 KiB
Swift

//
// 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<Response: Decodable>(
_ 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<Response: Decodable>(
_ 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<Body: Encodable, Response: Decodable>(
_ 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<Response: Decodable>(
_ 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<Body: Encodable>(_ method: AdminAPI.Method, _ path: String, body: Body) async throws -> AdminAck {
try await send(method, path, body: body, as: AdminAck.self)
}
private func _guard<Response>(
_ 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
}
}
}