mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user