fix: guard OAuth account removal
This commit is contained in:
@@ -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[]) =>
|
||||
|
||||
@@ -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...'
|
||||
},
|
||||
|
||||
@@ -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: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '正在載入提供方...'
|
||||
},
|
||||
|
||||
@@ -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: '正在加载提供方...'
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user