Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift
T

410 lines
17 KiB
Swift

import SwiftUI
import SafariServices
import NucleicProtocol
// Remote agent sign-in, phone UI (docs/REMOTE_AGENT_LOGIN.md §6): the Agent Accounts section in
// Settings (per-host provider status + Sign in), the sheet that drives one attempt (in-app
// Safari + loopback auto-capture, or Claude's paste-a-code fallback), and the in-chat banner
// that offers a sign-in when a turn dies on an auth failure.
/// The Settings ▸ Agent Accounts section: every live host's per-provider install/auth state
/// (from its `agentAuthStatus` push) with a Sign in button where that host can broker the flow.
struct AgentAccountsSection: View {
@EnvironmentObject var store: RemoteStore
/// The row whose "Use API key…" sheet is open — (host, provider, display name).
@State private var apiKeyTarget: APIKeyTarget?
/// The mesh-wide deletion awaiting the user's confirm (it tombstones the credential on
/// EVERY device, so it always confirms first). Nil hides the dialog.
@State private var deleteTarget: DeleteTarget?
struct APIKeyTarget: Identifiable {
let hostID: String
let hostName: String
let provider: AgentLoginProvider
let providerName: String
var id: String { hostID + "·" + provider.rawValue }
}
struct DeleteTarget: Identifiable {
let kind: CredentialKind
let label: String
var id: String { kind.rawValue }
}
var body: some View {
let hosts = store.agentAccountHosts
if !hosts.isEmpty {
Section {
ForEach(hosts, id: \.hostID) { host in
if hosts.count > 1 {
Text(host.name)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
ForEach(host.statuses, id: \.provider.rawValue) { status in
providerRow(status, hostID: host.hostID, hostName: host.name)
}
}
} header: {
Text("Agent accounts")
} footer: {
Text(footerText)
}
.sheet(item: $apiKeyTarget) { target in
APIKeyEntrySheet(target: target)
}
.confirmationDialog(
"Delete the \(deleteTarget?.label ?? "credential") from every device in your mesh?",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }),
titleVisibility: .visible
) {
Button("Delete Everywhere", role: .destructive) {
guard let target = deleteTarget else { return }
deleteTarget = nil
store.revokeCredential(kind: target.kind)
}
Button("Cancel", role: .cancel) { deleteTarget = nil }
} message: {
Text("Your Macs, cloud runners, and this iPhone all drop it. A tombstone keeps "
+ "any offline device from bringing it back; signing in again re-enables "
+ "the provider everywhere.")
}
}
}
/// The section footer: the standard sign-in explainer, plus — once this phone actually
/// holds mirrored logins — the holder note (an encrypted copy lives here, so a fresh
/// runner can be credentialed with every Mac asleep).
private var footerText: String {
var text = "Sign-ins run on the host — your Mac or a cloud runner — and sync to every "
+ "device in your mesh. This phone only shows the consent page and relays "
+ "the sign-in code over the encrypted channel."
let held = store.phoneVaultKinds.compactMap { kind -> String? in
switch kind {
case .claudeOAuth: "Claude"
case .codexAuth: "Codex"
default: nil
}
}
if !held.isEmpty {
text += " This iPhone also keeps an encrypted copy of the "
+ held.joined(separator: " and ")
+ " sign-in, so it can credential a fresh runner on its own."
}
return text
}
@ViewBuilder
private func providerRow(
_ status: WireProviderAuthStatus, hostID: String, hostName: String
) -> some View {
let name = status.name.isEmpty ? status.provider.rawValue.capitalized : status.name
HStack(spacing: 10) {
Image(systemName: status.authenticated ? "checkmark.seal.fill" : "person.crop.circle.badge.questionmark")
.foregroundStyle(status.authenticated ? Color.green : Color.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(name)
Text(detail(for: status))
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if status.canBrokerLogin {
Button(status.authenticated ? "Sign in again" : "Sign in") {
store.beginAgentLogin(provider: status.provider, onHost: hostID)
}
.buttonStyle(.borderless)
.font(.callout)
}
// The ToS-defensive fallback (REMOTE_AGENT_LOGIN §8): set a Console/API key
// instead of a subscription login. Only for the two key-backed providers, and only
// when the host can take a sealed key from this phone.
if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) {
Button {
apiKeyTarget = APIKeyTarget(
hostID: hostID, hostName: hostName,
provider: status.provider, providerName: name)
} label: {
Image(systemName: "key")
}
.buttonStyle(.borderless)
.accessibilityLabel("Use an API key for \(name)")
}
}
.contextMenu {
// Mesh-wide deletion (key deletion from any device): offered when a host that
// accepts the tombstone verb is reachable — the deletion then propagates from it
// to every other member (and this phone clears its own vault copy regardless).
if status.authenticated, store.canRevokeCredentials(onHost: hostID) {
if status.method == "apiKey" {
Button(role: .destructive) {
deleteTarget = DeleteTarget(
kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey,
label: "\(name) API key")
} label: {
Label("Delete API Key on All Devices…", systemImage: "trash")
}
} else {
Button(role: .destructive) {
deleteTarget = DeleteTarget(
kind: status.provider == .codex ? .codexAuth : .claudeOAuth,
label: "\(name) sign-in")
} label: {
Label("Sign Out on All Devices…", systemImage: "trash")
}
}
}
}
}
private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] }
private func detail(for status: WireProviderAuthStatus) -> String {
switch (status.installed, status.authenticated) {
case (true, true):
if let label = status.accountLabel, !label.isEmpty { return "Signed in · \(label)" }
if status.method == "apiKey" { return "Signed in · API key" }
return "Signed in"
case (true, false): return "Installed, not signed in"
case (false, _): return "Not installed on this host"
}
}
}
/// SecureField entry for a provider API key, sealed straight to the chosen host
/// (REMOTE_AGENT_LOGIN §8). The phone never stores the key; confirmation is implicit — the
/// provider row flips to "Signed in · API key" when the host's refreshed status push lands.
private struct APIKeyEntrySheet: View {
@EnvironmentObject var store: RemoteStore
@Environment(\.dismiss) private var dismiss
let target: AgentAccountsSection.APIKeyTarget
@State private var key = ""
@State private var sent = false
@State private var failed = false
private var keyName: String {
target.provider == .claude ? "Anthropic API key" : "OpenAI API key"
}
var body: some View {
NavigationStack {
Form {
if sent {
Section {
Label("Key sent to \(target.hostName)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Text("The \(target.providerName) row will show “API key” once it lands.")
.font(.footnote).foregroundStyle(.secondary)
}
} else {
Section {
SecureField(keyName, text: $key)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if failed {
Text("Couldn't send the key — the host may have disconnected. Try again.")
.font(.footnote).foregroundStyle(.red)
}
} footer: {
Text("Sealed to \(target.hostName) over the encrypted channel and stored "
+ "there — never on this phone. Replaces any key already set.")
}
}
}
.navigationTitle("\(target.providerName) API key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(sent ? "Done" : "Cancel") { dismiss() }
}
if !sent {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let ok = store.submitAPIKey(
key, provider: target.provider, toHost: target.hostID)
sent = ok
failed = !ok
}
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
.presentationDetents([.medium])
}
}
/// Drives one sign-in attempt end to end, rendering whatever the flow state asks for: a spinner
/// while the host builds the challenge, the vendor's consent page (with the loopback armed for
/// auto-capture, or a paste bar for Claude's console fallback), and the final outcome.
struct AgentLoginSheet: View {
@EnvironmentObject var store: RemoteStore
@State private var pastedCode = ""
private var providerLabel: String {
switch store.agentLoginProvider {
case .some(.claude): "Claude"
case .some(.codex): "Codex"
case .some(let other): other.rawValue.capitalized
case .none: "Agent"
}
}
var body: some View {
NavigationStack {
content
.navigationTitle("Sign in to \(providerLabel)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(isDone ? "Done" : "Cancel") { store.cancelAgentLogin() }
}
}
}
.interactiveDismissDisabled(!isDone)
}
private var isDone: Bool {
if case .done = store.agentLogin { return true }
return false
}
@ViewBuilder
private var content: some View {
switch store.agentLogin {
case .idle, .starting:
ProgressView("Contacting host…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .browser(let url):
SafariView(url: url)
.ignoresSafeArea(edges: .bottom)
case .pasteCode(let url):
SafariView(url: url)
.ignoresSafeArea(edges: .bottom)
.safeAreaInset(edge: .bottom) { pasteBar }
case .finishing:
ProgressView("Completing sign-in…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .done(let success, let message):
VStack(spacing: 12) {
Image(systemName: success ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 44))
.foregroundStyle(success ? Color.green : Color.orange)
Text(success
? "\(providerLabel) is signed in. Every device in your mesh can use it."
: (message ?? "Sign-in did not complete."))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
Button("Done") { store.cancelAgentLogin() }
.buttonStyle(.borderedProminent)
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Claude's console fallback renders a `code#state` blob on the consent page — the user
/// copies it there and pastes it here; the host does the exchange.
private var pasteBar: some View {
HStack(spacing: 8) {
TextField("Paste the code shown by the sign-in page", text: $pastedCode)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
Button("Submit") {
store.submitPastedLoginCode(pastedCode)
}
.buttonStyle(.borderedProminent)
.disabled(pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(10)
.background(.bar)
}
}
/// In-app Safari for the vendor's consent page. `SFSafariViewController` (not
/// `ASWebAuthenticationSession`) on purpose: its callback API can't intercept a plain
/// `http://localhost` redirect, and keeping the app foreground keeps the loopback listener and
/// the host socket alive for the whole round-trip — Safari-on-device resolves `localhost` to
/// this phone, which is the entire capture trick.
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let controller = SFSafariViewController(url: url)
controller.dismissButtonStyle = .cancel
return controller
}
func updateUIViewController(_ controller: SFSafariViewController, context: Context) {}
}
/// The auth-failure matcher the in-chat banner keys on — mirrors the Mac's
/// `TranscriptRow.isAuthError` so both surfaces light up on the same failures.
enum AgentAuthErrors {
static func isAuthError(_ text: String) -> Bool {
let lowered = text.lowercased()
return lowered.contains("401")
|| lowered.contains("authentication_error")
|| lowered.contains("authentication error")
|| lowered.contains("not logged in")
|| lowered.contains("oauth token has expired")
|| lowered.contains("please run /login")
|| lowered.contains("invalid api key")
|| lowered.contains("credential")
&& lowered.contains("expired")
}
/// Whether the tail of a transcript ended on an auth failure — the banner's trigger. Only
/// the events after the last completed run matter: a re-auth mid-history shouldn't nag.
static func transcriptNeedsLogin(_ events: [AgentEvent]) -> Bool {
for event in events.suffix(30).reversed() {
switch event.kind {
case .runFinished(let finished):
guard finished.outcome == .errored else { return false }
return finished.finalText.map(isAuthError) ?? false
case .error(let error):
if isAuthError(error.message) { return true }
default:
continue
}
}
return false
}
}
/// The in-chat re-auth affordance: shown above the composer when the open session's last run
/// died on an auth failure and a connected host can broker the matching provider's sign-in.
struct AgentAuthErrorBanner: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
let backend: BackendID
var body: some View {
if AgentLoginProvider.forBackend(backend) != nil,
AgentAuthErrors.transcriptNeedsLogin(store.openEvents)
{
HStack(spacing: 10) {
Image(systemName: "key.fill")
.foregroundStyle(.orange)
Text("The agent isn't signed in.")
.font(.callout)
Spacer()
Button("Sign in") {
store.beginAgentLogin(forSession: sessionID)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 12)
}
}
}