fix: guard OAuth account removal

This commit is contained in:
Teknium
2026-06-13 21:47:13 -07:00
parent e986e3fc68
commit 1b16c48170
11 changed files with 175 additions and 30 deletions
@@ -21,16 +21,18 @@ vi.mock('@/store/onboarding', () => ({
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
}))
function provider(id: string, loggedIn: boolean): OAuthProvider {
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
return {
cli_command: `hermes auth add ${id}`,
disconnectable: true,
docs_url: '',
flow: 'device_code',
id,
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
status: {
logged_in: loggedIn
}
},
...patch
}
}
@@ -75,4 +77,24 @@ describe('ProvidersSettings', () => {
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
})
it('does not offer removal for externally managed providers', async () => {
listOAuthProviders.mockResolvedValue({
providers: [
provider('qwen-oauth', true, {
cli_command: 'hermes auth add qwen-oauth',
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
disconnectable: false,
flow: 'external',
name: 'Qwen (via Qwen CLI)'
})
]
})
await renderProvidersSettings()
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
})
})
@@ -6,6 +6,7 @@ import {
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
@@ -187,8 +188,13 @@ function ConnectedProviderRow({
provider: OAuthProvider
}) {
const { t } = useI18n()
const title = provider.name
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
const disconnectHint = provider.flow === 'external'
? t.settings.providers.removeExternal(title, provider.cli_command)
: t.settings.providers.removeKeyManaged(title)
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
@@ -201,20 +207,27 @@ function ConnectedProviderRow({
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{!canDisconnect && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{disconnectHint}
</p>
)}
</button>
<div className="flex items-center gap-1 pr-2">
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
<Button
aria-label={`${t.common.remove} ${title}`}
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
{canDisconnect && (
<Button
aria-label={`${t.common.remove} ${title}`}
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
</div>
</div>
)
@@ -270,9 +283,9 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
}, [onboardingActive])
async function handleDisconnect(provider: OAuthProvider) {
const name = provider.name
const name = providerTitle(provider)
if (!window.confirm(`Remove ${name}?`)) {
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
return
}
@@ -280,10 +293,10 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
try {
await disconnectOAuthProvider(provider.id)
notify({ durationMs: 3_000, kind: 'success', message: `${name} removed.` })
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
await refreshOAuthProviders().catch(() => undefined)
} catch (err) {
notifyError(err, `Could not remove ${name}`)
notifyError(err, t.settings.providers.failedRemove(name))
} finally {
setDisconnecting(null)
}
@@ -180,7 +180,7 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
export const sortProviders = (providers: OAuthProvider[]) =>
+6
View File
@@ -513,6 +513,12 @@ export const en: Translations = {
collapse: 'Collapse',
connectAnother: 'Connect another provider',
otherProviders: 'Other providers',
removeConfirm: provider => `Remove ${provider}?`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`,
noProviderKeys: 'No provider API keys available.',
loading: 'Loading providers...'
},
+6
View File
@@ -642,6 +642,12 @@ export const ja = defineLocale({
collapse: '折りたたむ',
connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`,
failedRemove: provider => `${provider} を削除できませんでした`,
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
loading: 'プロバイダーを読み込み中...'
},
+6
View File
@@ -413,6 +413,12 @@ export interface Translations {
collapse: string
connectAnother: string
otherProviders: string
removeConfirm: (provider: string) => string
removeExternal: (provider: string, command: string) => string
removeKeyManaged: (provider: string) => string
removedTitle: string
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
noProviderKeys: string
loading: string
}
+6
View File
@@ -621,6 +621,12 @@ export const zhHant = defineLocale({
collapse: '收合',
connectAnother: '連結其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `無法移除 ${provider}`,
noProviderKeys: '沒有可用的提供方 API 金鑰。',
loading: '正在載入提供方...'
},
+6
View File
@@ -708,6 +708,12 @@ export const zh: Translations = {
collapse: '收起',
connectAnother: '连接其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
noProviderKeys: '没有可用的提供方 API 密钥。',
loading: '正在加载提供方...'
},
+2
View File
@@ -47,6 +47,8 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
disconnect_hint?: null | string
disconnectable?: boolean
docs_url: string
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
id: string