commit 3353c52c82cff9c50c6a44fdfc01f3131d107130
parent 06c1dc402ec42601719422a9f07d7c8093cfee52
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 13:25:14 -0700
user-presence: gate identity actions
Require verified user presence before unlocking, saving, importing, or deleting local Nostr identity material.
Expose a small setup/settings status surface so deterministic UI tests can prove the requested gate outcome.
Diffstat:
3 files changed, 43 insertions(+), 0 deletions(-)
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -44,6 +44,7 @@ public final class AppState: ObservableObject {
@Published public private(set) var fileAccessProbeValue: String?
@Published public private(set) var documentInterchangeProbeValue: String?
@Published public private(set) var externalActionStatus: String?
+ @Published public private(set) var userPresenceStatus: String?
@Published public private(set) var canOpenNostrProfile: Bool = false
@Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle(
RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable)
@@ -82,6 +83,7 @@ public final class AppState: ObservableObject {
private var captureIntake: FieldCaptureIntake?
private let locationCheckIn = FieldLocationCheckIn.configured()
private let externalActions = FieldExternalActions.configured()
+ private let userPresenceGate = FieldUserPresenceGate.configured()
public init(radroots: Radroots = Radroots()) {
self.radroots = radroots
@@ -161,6 +163,7 @@ public final class AppState: ObservableObject {
public func continueWithLocalIdentity() async throws {
let service = try requireRuntimeService()
+ try await requireUserPresence(for: .unlockIdentity)
try await restoreStoredIdentity(using: service)
setLocked(false)
await refreshRuntimeState(using: service)
@@ -170,6 +173,7 @@ public final class AppState: ObservableObject {
public func createLocalIdentity() async throws {
let service = try requireRuntimeService()
+ try await requireUserPresence(for: .saveIdentity)
try await createHostCustodyIdentity(using: service)
setLocked(false)
await refreshRuntimeState(using: service)
@@ -181,6 +185,7 @@ public final class AppState: ObservableObject {
let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let service = try requireRuntimeService()
+ try await requireUserPresence(for: .saveIdentity)
let record = try await secureIdentityStoreOrConfigured().importSecret(
trimmed,
label: "Imported Field Identity",
@@ -207,6 +212,7 @@ public final class AppState: ObservableObject {
public func resetLocalIdentity() async throws {
let service = try requireRuntimeService()
+ try await requireUserPresence(for: .deleteIdentity)
try secureIdentityStoreOrConfigured().deleteSelectedSecret()
try identityMetadataStoreOrConfigured().delete()
try await resetRuntimeIdentityState(using: service)
@@ -533,6 +539,16 @@ public final class AppState: ObservableObject {
try persistIdentity(record)
}
+ private func requireUserPresence(for action: FieldUserPresenceAction) async throws {
+ do {
+ let record = try await userPresenceGate.requirePresence(for: action)
+ userPresenceStatus = record.statusText
+ } catch {
+ userPresenceStatus = error.localizedDescription
+ throw error
+ }
+ }
+
private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws {
let record = try await secureIdentityStoreOrConfigured().createIdentity(
label: "Radroots Field",
diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift
@@ -47,6 +47,12 @@ struct SettingsView: View {
value: app.runtimeIdentityReady ? "Unlocked" : "Locked",
identifier: "field_ios.settings.runtime_identity"
)
+ if let userPresenceStatus = app.userPresenceStatus {
+ Text(userPresenceStatus)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .accessibilityIdentifier("field_ios.user_presence.status")
+ }
NavigationLink {
ProfileView()
@@ -101,6 +107,7 @@ struct SettingsView: View {
if let resetError {
Text(resetError)
.foregroundStyle(.red)
+ .accessibilityIdentifier("field_ios.settings.reset_error")
}
}
}
diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift
@@ -41,6 +41,7 @@ struct SetupView: View {
}
SetupErrorText(errorMessage)
+ UserPresenceStatusText(app.userPresenceStatus)
if isWorking {
ProgressView()
@@ -186,3 +187,22 @@ private struct SetupErrorText: View {
}
}
}
+
+private struct UserPresenceStatusText: View {
+ let message: String?
+
+ init(_ message: String?) {
+ self.message = message
+ }
+
+ var body: some View {
+ if let message {
+ Text(message)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ .accessibilityIdentifier("field_ios.user_presence.status")
+ }
+ }
+}