cc-haha/desktop/src/pages/AdapterSettings.tsx
程序员阿江(Relakkes) 8c99efca20 Prevent destructive desktop actions from bypassing confirmation
The desktop settings and sidebar still had deletion flows that either used browser-native confirms or deleted immediately. This change consolidates destructive confirmations behind a shared dialog component, applies it to provider deletion, plugin uninstall, adapter unbind, and sidebar session deletion, and adds regression coverage so delete actions require an explicit second confirmation before mutating state.

Constraint: Other in-progress desktop work in the tree had to stay out of this commit
Constraint: Existing MCP and task confirmations needed to keep their current behavior
Rejected: Leave confirmations embedded per-page with browser dialogs | inconsistent UX and easy to regress
Rejected: Add confirmations only to provider deletion | leaves other destructive desktop flows unsafe
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any new desktop delete, uninstall, or unbind action should use the shared ConfirmDialog instead of browser-native dialogs or one-click deletion
Tested: bun run test src/components/layout/Sidebar.test.tsx src/__tests__/generalSettings.test.tsx; bun run lint
Not-tested: Manual desktop click-through of every destructive action after this refactor
2026-04-22 17:30:59 +08:00

404 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react'
import { useAdapterStore } from '../stores/adapterStore'
import { useTranslation } from '../i18n'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
type ImTab = 'feishu' | 'telegram'
export function AdapterSettings() {
const t = useTranslation()
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
// Active IM tab —— Feishu 默认展示,在前
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
// 桌面端用 Tauri env var 注入动态端口。
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// Telegram
const [tgBotToken, setTgBotToken] = useState('')
const [tgAllowedUsers, setTgAllowedUsers] = useState('')
// Feishu
const [fsAppId, setFsAppId] = useState('')
const [fsAppSecret, setFsAppSecret] = useState('')
const [fsEncryptKey, setFsEncryptKey] = useState('')
const [fsVerificationToken, setFsVerificationToken] = useState('')
const [fsAllowedUsers, setFsAllowedUsers] = useState('')
const [fsStreamingCard, setFsStreamingCard] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle')
const [saveError, setSaveError] = useState('')
// Pairing
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [pendingUnbind, setPendingUnbind] = useState<{ platform: 'telegram' | 'feishu'; userId: string | number } | null>(null)
const [isUnbinding, setIsUnbinding] = useState(false)
useEffect(() => {
fetchConfig()
}, [])
// Sync form state when config is loaded
useEffect(() => {
setDefaultProjectDir(config.defaultProjectDir ?? '')
setTgBotToken(config.telegram?.botToken ?? '')
setTgAllowedUsers(config.telegram?.allowedUsers?.join(', ') ?? '')
setFsAppId(config.feishu?.appId ?? '')
setFsAppSecret(config.feishu?.appSecret ?? '')
setFsEncryptKey(config.feishu?.encryptKey ?? '')
setFsVerificationToken(config.feishu?.verificationToken ?? '')
setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '')
setFsStreamingCard(config.feishu?.streamingCard ?? false)
}, [config])
async function handleSave() {
setIsSaving(true)
setSaveStatus('idle')
setSaveError('')
try {
const patch: Record<string, unknown> = {}
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
const tgUsers = tgAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n))
patch.telegram = {
botToken: tgBotToken || undefined,
allowedUsers: tgUsers.length ? tgUsers : [],
}
const fsUsers = fsAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.feishu = {
appId: fsAppId || undefined,
appSecret: fsAppSecret || undefined,
encryptKey: fsEncryptKey || undefined,
verificationToken: fsVerificationToken || undefined,
allowedUsers: fsUsers.length ? fsUsers : [],
streamingCard: fsStreamingCard,
}
await updateConfig(patch)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
} catch (err) {
setSaveStatus('error')
setSaveError(err instanceof Error ? err.message : 'Save failed')
} finally {
setIsSaving(false)
}
}
const handleGenerateCode = useCallback(async () => {
setIsGenerating(true)
try {
const code = await generatePairingCode()
setPairingCode(code)
} catch (err) {
console.error('Failed to generate pairing code:', err)
} finally {
setIsGenerating(false)
}
}, [generatePairingCode])
const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu', userId: string | number) => {
setPendingUnbind({ platform, userId })
}, [])
const confirmUnbind = useCallback(async () => {
if (!pendingUnbind) return
setIsUnbinding(true)
try {
await removePairedUser(pendingUnbind.platform, pendingUnbind.userId)
await fetchConfig()
setPendingUnbind(null)
} finally {
setIsUnbinding(false)
}
}, [pendingUnbind, removePairedUser, fetchConfig])
// Collect all paired users across platforms
const allPairedUsers = [
...(config.telegram?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'telegram' as const })),
...(config.feishu?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'feishu' as const })),
]
// Check pairing expiry
const pairingExpiry = config.pairing?.expiresAt
const isPairingActive = pairingExpiry ? Date.now() < pairingExpiry : false
const minutesLeft = pairingExpiry ? Math.max(0, Math.ceil((pairingExpiry - Date.now()) / 60000)) : 0
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[20px] mr-2">progress_activity</span>
Loading...
</div>
)
}
return (
<div className="max-w-2xl space-y-8">
{/* Description */}
<div>
<p className="text-sm text-[var(--color-text-secondary)]">{t('settings.adapters.description')}</p>
</div>
{/* Pairing */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">link</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.pairing')}</span>
</div>
<div className="p-4 space-y-4">
<p className="text-sm text-[var(--color-text-secondary)]">{t('settings.adapters.pairingDesc')}</p>
{/* Generate code */}
<div className="flex items-center gap-3">
<Button onClick={handleGenerateCode} loading={isGenerating}>
{pairingCode || isPairingActive ? t('settings.adapters.regenerateCode') : t('settings.adapters.generateCode')}
</Button>
{pairingCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-2xl font-bold tracking-[0.3em] text-[var(--color-brand)]">
{pairingCode}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.codeExpiresIn')} 60 {t('settings.adapters.minutes')}
</span>
</div>
)}
{!pairingCode && isPairingActive && (
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.codeExpiresIn')} {minutesLeft} {t('settings.adapters.minutes')}
</span>
)}
</div>
{pairingCode && (
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.pairingCodeHint')}</p>
)}
{/* Paired users list */}
<div>
<h4 className="text-sm font-medium text-[var(--color-text-primary)] mb-2">{t('settings.adapters.pairedUsers')}</h4>
{allPairedUsers.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.adapters.noPairedUsers')}</p>
) : (
<div className="space-y-2">
{allPairedUsers.map((user) => (
<div
key={`${user.platform}-${user.userId}`}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
{t(`settings.adapters.platform.${user.platform}`)}
</span>
<span className="text-sm text-[var(--color-text-primary)]">{user.displayName}</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{new Date(user.pairedAt).toLocaleDateString()}
</span>
</div>
<button
onClick={() => handleUnbind(user.platform, user.userId)}
className="text-xs text-[var(--color-error)] hover:underline cursor-pointer"
>
{t('settings.adapters.unbind')}
</button>
</div>
))}
</div>
)}
</div>
</div>
</section>
{/* Server URL —— 之前是个手填字段,但桌面端 Tauri 启动 adapter sidecar
时已经把 server 的动态端口通过 ADAPTER_SERVER_URL env var 注进去了,
loadConfig() 里 env 优先级高于这里的 file value所以这个字段在桌面
运行时完全不会被读到。用户也根本不知道该填什么端口(每次启动随机)。
Standalone 模式(直接 bun run adapters/...)保留 file 字段兜底就够了。 */}
{/* Default Project */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
{/* IM Adapter Tabs —— Feishu 默认在前Telegram 在后 */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div role="tablist" aria-label="IM adapter" className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
<ImTabButton
label={t('settings.adapters.feishu')}
active={activeIm === 'feishu'}
onClick={() => setActiveIm('feishu')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
onClick={() => setActiveIm('telegram')}
/>
</div>
{activeIm === 'feishu' && (
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
value={fsAppId}
onChange={(e) => setFsAppId(e.target.value)}
placeholder={t('settings.adapters.appIdPlaceholder')}
/>
<Input
label={t('settings.adapters.appSecret')}
type="password"
value={fsAppSecret}
onChange={(e) => setFsAppSecret(e.target.value)}
placeholder={t('settings.adapters.appSecretPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.encryptKey')}
type="password"
value={fsEncryptKey}
onChange={(e) => setFsEncryptKey(e.target.value)}
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
/>
<Input
label={t('settings.adapters.verificationToken')}
type="password"
value={fsVerificationToken}
onChange={(e) => setFsVerificationToken(e.target.value)}
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={fsAllowedUsers}
onChange={(e) => setFsAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={fsStreamingCard}
onChange={(e) => setFsStreamingCard(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
</div>
</label>
</div>
)}
{activeIm === 'telegram' && (
<div className="p-4 space-y-4">
<Input
label={t('settings.adapters.botToken')}
type="password"
value={tgBotToken}
onChange={(e) => setTgBotToken(e.target.value)}
placeholder={t('settings.adapters.botTokenPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={tgAllowedUsers}
onChange={(e) => setTgAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
)}
</section>
{/* Save */}
<div className="flex items-center gap-3">
<Button onClick={handleSave} loading={isSaving}>
{saveStatus === 'saved' ? t('settings.adapters.saved') : t('settings.adapters.save')}
</Button>
{saveStatus === 'saved' && (
<span className="text-sm text-[var(--color-success)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">check_circle</span>
{t('settings.adapters.saved')}
</span>
)}
{saveStatus === 'error' && (
<span className="text-sm text-[var(--color-error)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">error</span>
{saveError}
</span>
)}
</div>
<ConfirmDialog
open={pendingUnbind !== null}
onClose={() => {
if (isUnbinding) return
setPendingUnbind(null)
}}
onConfirm={confirmUnbind}
title={t('settings.adapters.unbind')}
body={t('settings.adapters.unbindConfirm')}
confirmLabel={t('settings.adapters.unbind')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUnbinding}
/>
</div>
)
}
function ImTabButton({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`relative px-4 py-2.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-inset ${
active
? 'text-[var(--color-text-primary)] font-semibold after:absolute after:left-3 after:right-3 after:bottom-0 after:h-[2px] after:bg-[var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{label}
</button>
)
}