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.
293 lines
8.6 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|