mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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) {
|
func resetProvider(_ provider: String) {
|
||||||
let result = runHermes(["auth", "reset", provider])
|
let result = runHermes(["auth", "reset", provider])
|
||||||
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
|
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 viewModel: CredentialPoolsViewModel
|
||||||
@State private var showAddSheet = false
|
@State private var showAddSheet = false
|
||||||
@State private var pendingRemove: HermesCredential?
|
@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
|
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
|
||||||
/// provider name + OAuth type — driven by the chat banner's
|
/// provider name + OAuth type — driven by the chat banner's
|
||||||
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
|
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
|
||||||
@@ -14,6 +17,7 @@ struct CredentialPoolsView: View {
|
|||||||
/// "Add Credential" press doesn't accidentally inherit it.
|
/// "Add Credential" press doesn't accidentally inherit it.
|
||||||
@State private var reauthInitialProvider: String?
|
@State private var reauthInitialProvider: String?
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
/// Mirror of `OAuthKeepaliveCronService.isEnabled()` so the
|
/// Mirror of `OAuthKeepaliveCronService.isEnabled()` so the
|
||||||
/// toggle reads from local @State (instant) instead of hitting
|
/// toggle reads from local @State (instant) instead of hitting
|
||||||
@@ -78,6 +82,17 @@ struct CredentialPoolsView: View {
|
|||||||
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
|
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
|
||||||
consumePendingReauth()
|
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: {
|
.sheet(isPresented: $showAddSheet, onDismiss: {
|
||||||
// Refresh after every dismiss — the OAuth flow rewrites
|
// Refresh after every dismiss — the OAuth flow rewrites
|
||||||
// `auth.json` on success, but the sheet self-closes
|
// `auth.json` on success, but the sheet self-closes
|
||||||
@@ -107,6 +122,20 @@ struct CredentialPoolsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
|
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
|
/// 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
|
// styled Text. Plain string preserves the backticks
|
||||||
// literally.
|
// literally.
|
||||||
.help(Text(verbatim: "Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens."))
|
.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(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(.quaternary.opacity(0.3))
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
HStack {
|
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)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
Reference in New Issue
Block a user