/**
* Integrations section (RFC-041 Phase 2).
*
* Lists registry integrations with live detect + credential status. `secret`
* resolvers get an inline set/remove control; `oauth` shows a placeholder
* "Connect" button (Phase 3 wires the device-code flow); `none` shows no
* credential UI. This supersedes the host-tools-only view for integrations,
* while host-tools remains the raw inventory surface.
*/
import { CheckCircle2, Eye, EyeOff, Loader2, Trash2, XCircle } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { OAuthModal } from '@/components/settings/oauth-modal'
import { SectionCard } from '@/components/settings/section-card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { IntegrationRow } from '@/hooks/use-integrations'
import {
useDeleteIntegrationCredential,
useInstallIntegration,
useIntegrations,
useSetIntegrationCredential,
} from '@/hooks/use-integrations'
function DetectedBadge({ detected }: { detected: IntegrationRow['detected'] }) {
const { t } = useTranslation()
if (!detected) return null
if (!detected.installed) {
return (
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">
{t('settings.integrationsNotInstalled')}
</Badge>
)
}
return (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
{detected.version && (
<span className="text-xs text-muted-foreground">{detected.version}</span>
)}
<Badge variant="outline" className="border-info/30 text-info">
{detected.source}
</Badge>
</div>
)
}
function CredentialBadge({ configured, source }: { configured: boolean; source: string }) {
const { t } = useTranslation()
return (
<Badge
variant="outline"
className={
configured
? 'border-emerald-500/30 text-emerald-500'
: 'border-muted-foreground/30 text-muted-foreground'
}
>
{configured ? t('settings.integrationsConfigured') : t('settings.integrationsNotConfigured')}
{configured && source !== 'none' ? ` · ${source}` : ''}
</Badge>
)
}
function SecretControl({ row }: { row: IntegrationRow }) {
const { t } = useTranslation()
const [value, setValue] = useState('')
const [show, setShow] = useState(false)
const setMut = useSetIntegrationCredential()
const delMut = useDeleteIntegrationCredential()
const onSave = async () => {
if (!value) return
try {
await setMut.mutateAsync({ id: row.id, value })
setValue('')
toast.success(t('settings.integrationsCredentialSaved'))
} catch {
toast.error(t('settings.integrationsCredentialError'))
}
}
const onRemove = async () => {
try {
await delMut.mutateAsync(row.id)
toast.success(t('settings.integrationsCredentialRemoved'))
} catch {
toast.error(t('settings.integrationsCredentialError'))
}
}
return (
<div className="flex items-center gap-2">
<div className="relative">
<Input
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('settings.integrationsEnterValue')}
className="h-8 w-48 pr-8 font-mono text-xs"
/>
<button
type="button"
aria-label={
show
? t('settings.integrationsHideCredential')
: t('settings.integrationsShowCredential')
}
onClick={() => setShow((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={!value || setMut.isPending}>
{setMut.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('common.save')}
</Button>
{row.credential.configured && (
<Button
size="sm"
variant="ghost"
aria-label={t('settings.integrationsRemoveCredential')}
onClick={onRemove}
disabled={delMut.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
)
}
function InstallButton({ row }: { row: IntegrationRow }) {
const { t } = useTranslation()
const installMut = useInstallIntegration()
// Only show Install when the integration has a CLI and it is NOT installed.
// Credential-only integrations (no cli) and already-installed ones hide it.
const notInstalled = row.cli && row.detected && !row.detected.installed
if (!notInstalled) return null
const onInstall = async () => {
if (!window.confirm(t('settings.integrationsInstallConfirm', { name: row.label }))) return
try {
const res = await installMut.mutateAsync(row.id)
if (res.success) toast.success(t('settings.integrationsInstallDone'))
else
toast.error(t('settings.integrationsInstallFailed') + (res.output ? `\n${res.output}` : ''))
} catch (e) {
toast.error(`${t('settings.integrationsInstallFailed')} (${String(e)})`)
}
}
return (
<Button size="sm" variant="outline" onClick={onInstall} disabled={installMut.isPending}>
{installMut.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('settings.integrationsInstall')}
</Button>
)
}
export function IntegrationsSectionCard() {
const { t } = useTranslation()
const { data: rows, isLoading, isError } = useIntegrations()
const [oauthRow, setOauthRow] = useState<IntegrationRow | null>(null)
return (
<SectionCard
title={t('settings.sectionHostTools')}
description={t('settings.integrationsDescription')}
sectionId="host-tools"
>
{isLoading && (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('settings.hostToolsScanning')}
</div>
)}
{isError && (
<div className="flex items-center gap-2 py-8 text-sm text-destructive">
<XCircle className="h-4 w-4" />
{t('settings.hostToolsError')}
</div>
)}
{rows && rows.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
{t('settings.integrationsNone')}
</div>
)}
{rows && rows.length > 0 && (
<ul className="space-y-2">
{rows.map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted/40"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium">{row.label}</span>
{row.cli && <span className="text-xs text-muted-foreground">{row.cli}</span>}
</div>
<div className="flex items-center gap-3">
<DetectedBadge detected={row.detected} />
{row.resolverKind !== 'none' && (
<CredentialBadge
configured={row.credential.configured}
source={row.credential.source}
/>
)}
</div>
</div>
<InstallButton row={row} />
{row.resolverKind === 'secret' && <SecretControl row={row} />}
{row.resolverKind === 'oauth' && (
<Button size="sm" variant="outline" onClick={() => setOauthRow(row)}>
{row.credential.configured
? t('settings.integrationsConnected')
: t('settings.integrationsConnect')}
</Button>
)}
</li>
))}
</ul>
)}
<OAuthModal row={oauthRow} onOpenChange={(o) => !o && setOauthRow(null)} />
</SectionCard>
)
}