feat(credential-pools): OAuth remove button + auto-refresh on auth.json change

User reports the Nous OAuth provider still showed in the
credential pool after they 'removed' it, and Reload didn't help.
Two underlying bugs:

**Bug 1 — no UI path to remove OAuth providers.** The pool view
had a Re-authenticate button on each OAuth row but no remove.
Users who switched active provider thought that removed Nous;
the OAuth tokens stayed in auth.json and the row kept rendering.
Add a trash icon next to Re-authenticate that calls
`hermes auth logout <provider>` after a confirmation dialog.
ViewModel route is `removeOAuthProvider` mirroring
`removeCredential`.

**Bug 2 — view didn't refresh on external auth.json changes.**
Pool view subscribed only to .onAppear and sheet-dismiss. A
terminal `hermes auth logout` or another window's OAuth flow
left the view stale until manually re-entered. Wire up
`fileWatcher.lastChangeDate` so any auth.json mtime tick
triggers a reload (the file watcher already polls auth.json
on the remote SSH path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 12:46:41 +02:00
parent f6dc45b397
commit 4684b9deed
2 changed files with 66 additions and 1 deletions
@@ -342,6 +342,34 @@ final class CredentialPoolsViewModel {
}
}
/// Remove an OAuth provider from `auth.json`. Maps to
/// `hermes auth logout <provider>` Hermes' canonical verb for
/// dropping the access + refresh token entries from
/// `providers.<name>` while leaving the upstream account intact.
/// User-initiated; the credential pool view's trash button on
/// each OAuth row routes here after a confirmation dialog.
func removeOAuthProvider(_ provider: String) {
let result = runHermes(["auth", "logout", provider])
if result.exitCode == 0 {
message = "Removed OAuth provider \(provider)"
load()
} else {
// Surface the first output line in the toast so the user
// can tell whether the verb is missing on this Hermes
// version (older builds may not have `auth logout`) vs.
// an actual failure. `runHermes` returns combined output
// (stdout + stderr) in `output`; first non-empty line is
// the most useful tail.
let detail = result.output
.split(separator: "\n", omittingEmptySubsequences: true)
.first.map(String.init) ?? "exit \(result.exitCode)"
message = "Remove failed: \(detail)"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
func resetProvider(_ provider: String) {
let result = runHermes(["auth", "reset", provider])
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
@@ -6,6 +6,9 @@ struct CredentialPoolsView: View {
@State private var viewModel: CredentialPoolsViewModel
@State private var showAddSheet = false
@State private var pendingRemove: HermesCredential?
/// Mirrors `pendingRemove` for OAuth providers different model
/// type, separate confirmation. Non-nil while the dialog is up.
@State private var pendingOAuthRemove: HermesOAuthProvider?
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
/// provider name + OAuth type driven by the chat banner's
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
@@ -14,6 +17,7 @@ struct CredentialPoolsView: View {
/// "Add Credential" press doesn't accidentally inherit it.
@State private var reauthInitialProvider: String?
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
/// Mirror of `OAuthKeepaliveCronService.isEnabled()` so the
/// toggle reads from local @State (instant) instead of hitting
@@ -78,6 +82,17 @@ struct CredentialPoolsView: View {
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
consumePendingReauth()
}
// Pick up external changes to auth.json terminal
// `hermes auth logout`, OAuth flows from another window,
// OAuth keepalive cron rewriting tokens. Without this the
// pool only refreshes on appear / sheet-dismiss, so users
// who removed a provider via CLI saw stale rows after
// Reload (the file watcher already polls auth.json on the
// remote SSH path; here we just subscribe to its tick).
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
probeKeepalive()
}
.sheet(isPresented: $showAddSheet, onDismiss: {
// Refresh after every dismiss the OAuth flow rewrites
// `auth.json` on success, but the sheet self-closes
@@ -107,6 +122,20 @@ struct CredentialPoolsView: View {
} message: {
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
}
.confirmationDialog(
pendingOAuthRemove.map { "Remove OAuth provider \($0.provider.capitalized)?" } ?? "",
isPresented: Binding(get: { pendingOAuthRemove != nil }, set: { if !$0 { pendingOAuthRemove = nil } })
) {
Button("Remove", role: .destructive) {
if let target = pendingOAuthRemove {
viewModel.removeOAuthProvider(target.provider)
}
pendingOAuthRemove = nil
}
Button("Cancel", role: .cancel) { pendingOAuthRemove = nil }
} message: {
Text("Removes this OAuth provider from auth.json. You'll need to re-authenticate before Hermes can use it again. The upstream provider account is not revoked.")
}
}
/// Drain any pending re-auth hand-off from the chat banner: the
@@ -333,13 +362,21 @@ struct CredentialPoolsView: View {
// styled Text. Plain string preserves the backticks
// literally.
.help(Text(verbatim: "Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens."))
Button(role: .destructive) {
pendingOAuthRemove = provider
} label: {
Image(systemName: "trash")
}
.controlSize(.small)
.buttonStyle(.borderless)
.help(Text(verbatim: "Remove this OAuth provider from auth.json. Hermes will need to be re-authenticated to use it again."))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
HStack {
Text("Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal.")
Text("Re-authenticate refreshes tokens; the trash icon removes the provider from auth.json.")
.font(.caption2)
.foregroundStyle(.tertiary)
Spacer()