import Section from './Section' import React, { useEffect, useId, useRef, useState } from 'react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react' import { authApi, oauthApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import PhotoProvidersSection from './PhotoProvidersSection' import AirTrailConnectionSection from './AirTrailConnectionSection' import DawarichConnectionSection from './DawarichConnectionSection' import LlmConnectionSection from './LlmConnectionSection' import ApiKeysSection from './ApiKeysSection' import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' import { useAuthStore } from '../../store/authStore' import { getApiErrorMessage } from '../../utils/apiError' import ConfirmDialog from '../shared/ConfirmDialog' import { Tooltip } from '../shared/Tooltip' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts' import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit' interface OAuthPreset { id: string label: string name: string uris: string scopes: string[] } const OAUTH_PRESETS: OAuthPreset[] = [ { id: 'claude-web', label: 'Claude.ai', name: 'Claude.ai', uris: 'https://claude.ai/api/mcp/auth_callback', scopes: PRESET_SCOPES_DEFAULT, }, { id: 'claude-desktop', label: 'Claude Desktop', name: 'Claude Desktop', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT, }, { id: 'cursor', label: 'Cursor', name: 'Cursor', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT, }, { id: 'vscode', label: 'VS Code', name: 'VS Code / Copilot', uris: 'http://localhost', scopes: PRESET_SCOPES_READONLY, }, { id: 'windsurf', label: 'Windsurf', name: 'Windsurf', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT, }, { id: 'zed', label: 'Zed', name: 'Zed', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT, }, ] interface OAuthClient { id: string name: string client_id: string redirect_uris: string[] allowed_scopes: string[] allows_client_credentials: boolean created_at: string client_secret?: string // only present on create } interface OAuthSession { id: number client_id: string client_name: string scopes: string[] access_token_expires_at: string refresh_token_expires_at: string created_at: string } interface McpToken { id: number name: string token_prefix: string created_at: string last_used_at: string | null } export default function IntegrationsTab(): React.ReactElement { const S = useIntegrations() const managed = useAuthStore((s) => s.managed) return ( <> {/* Immich, Synology Photos and AirTrail all connect to a server the reader runs themselves. On a managed install there is none, and every one of these would ask for an address that cannot be reached from here. */} {!managed && } {S.airtrailEnabled && !managed && } {S.dawarichEnabled && !managed && } {/* Which model reads a booking, and what that costs, comes with the instance on a managed install. The per-user fallback exists for people who supply their own key, and there nobody does. */} {S.llmEnabled && !managed && } {/* Above MCP on purpose: an API key needs no addon, and someone looking for one should not have to read past a section about AI assistants. */} {S.mcpEnabled && } ) } function useIntegrations() { const { t, locale } = useTranslation() const toast = useToast() const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const mcpEnabled = addonEnabled('mcp') const airtrailEnabled = addonEnabled('airtrail') const llmEnabled = addonEnabled('llm_parsing') const dawarichEnabled = addonEnabled('dawarich') useEffect(() => { loadAddons() }, [loadAddons]) // OAuth clients state const [oauthClients, setOauthClients] = useState([]) const [oauthSessions, setOauthSessions] = useState([]) const [oauthCreateOpen, setOauthCreateOpen] = useState(false) const [oauthNewName, setOauthNewName] = useState('') const [oauthNewUris, setOauthNewUris] = useState('') const [oauthNewScopes, setOauthNewScopes] = useState([]) const [oauthCreating, setOauthCreating] = useState(false) const [oauthCreatedClient, setOauthCreatedClient] = useState(null) const [oauthDeleteId, setOauthDeleteId] = useState(null) const [oauthRevokeId, setOauthRevokeId] = useState(null) const [oauthRotateId, setOauthRotateId] = useState(null) const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null) const [oauthRotating, setOauthRotating] = useState(false) // oauthScopesOpen is managed internally by ScopeGroupPicker const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}) const [oauthIsMachine, setOauthIsMachine] = useState(false) // MCP sub-tab state const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth') const [configOpenOAuth, setConfigOpenOAuth] = useState(false) const [configOpenToken, setConfigOpenToken] = useState(false) // MCP state const [mcpTokens, setMcpTokens] = useState([]) const [mcpModalOpen, setMcpModalOpen] = useState(false) const [mcpNewName, setMcpNewName] = useState('') const [mcpCreatedToken, setMcpCreatedToken] = useState(null) const [mcpCreating, setMcpCreating] = useState(false) const [mcpDeleteId, setMcpDeleteId] = useState(null) const [copiedKey, setCopiedKey] = useState(null) const copyTimerRef = useRef | null>(null) useEffect(() => { return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) } }, []) const mcpEndpoint = `${window.location.origin}/mcp` const mcpJsonConfigOAuth = `{ "mcpServers": { "trek": { "command": "npx", "args": [ "mcp-remote", "${mcpEndpoint}", "--static-oauth-client-info", "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}" ] } } }` const mcpJsonConfig = `{ "mcpServers": { "trek": { "command": "npx", "args": [ "mcp-remote", "${mcpEndpoint}", "--header", "Authorization: Bearer " ] } } }` useEffect(() => { if (mcpEnabled) { authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) } }, [mcpEnabled]) const handleCreateMcpToken = async () => { if (!mcpNewName.trim()) return setMcpCreating(true) try { const d = await authApi.mcpTokens.create(mcpNewName.trim()) setMcpCreatedToken(d.token.raw_token) setMcpNewName('') setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev]) } catch { toast.error(t('settings.mcp.toast.createError')) } finally { setMcpCreating(false) } } const handleDeleteMcpToken = async (id: number) => { try { await authApi.mcpTokens.delete(id) setMcpTokens(prev => prev.filter(tk => tk.id !== id)) setMcpDeleteId(null) toast.success(t('settings.mcp.toast.deleted')) } catch { toast.error(t('settings.mcp.toast.deleteError')) } } const handleCopy = (text: string, key: string) => { void navigator.clipboard.writeText(text).then(() => { setCopiedKey(key) if (copyTimerRef.current) clearTimeout(copyTimerRef.current) copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000) }) } // Load OAuth clients and sessions useEffect(() => { if (mcpEnabled) { oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {}) oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {}) } }, [mcpEnabled]) const handleCreateOAuthClient = async () => { if (!oauthNewName.trim()) return if (!oauthIsMachine && !oauthNewUris.trim()) return setOauthCreating(true) try { const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes, ...(oauthIsMachine ? { allows_client_credentials: true } : {}), }) setOauthCreatedClient(d.client) setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) setOauthNewName('') setOauthNewUris('') setOauthNewScopes([]) setOauthIsMachine(false) } catch (err) { // The server names the rule that refused the client (a scope, the // ten-client cap, a redirect URI it will not take); swallowing it left // "Failed to register OAuth client" and nothing to act on. toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError'))) } finally { setOauthCreating(false) } } const handleDeleteOAuthClient = async (id: string) => { try { await oauthApi.clients.delete(id) setOauthClients(prev => prev.filter(c => c.id !== id)) setOauthDeleteId(null) toast.success(t('settings.oauth.toast.deleted')) } catch { toast.error(t('settings.oauth.toast.deleteError')) } } const handleRotateSecret = async (id: string) => { setOauthRotating(true) try { const d = await oauthApi.clients.rotate(id) setOauthRotatedSecret(d.client_secret) setOauthRotateId(null) } catch { toast.error(t('settings.oauth.toast.rotateError')) } finally { setOauthRotating(false) } } const handleRevokeSession = async (id: number) => { try { await oauthApi.sessions.revoke(id) setOauthSessions(prev => prev.filter(s => s.id !== id)) setOauthRevokeId(null) toast.success(t('settings.oauth.toast.revoked')) } catch { toast.error(t('settings.oauth.toast.revokeError')) } } return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession, } } type IntegrationsState = ReturnType /** An icon action of a row, named by its tooltip. */ const ICON_BUTTON_DANGER = SETTINGS_ICON_BUTTON.replace('hover:text-content', 'hover:text-danger') const SMALL_BUTTON = 'inline-flex flex-none items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1 font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint' const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted' const ROW = 'flex gap-3 px-3.5 py-3' const ROW_TILE = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted' const EYEBROW = 'mb-2 font-geist font-bold uppercase tracking-[.08em] text-content-faint' const PRESET_CHIP = 'inline-flex items-center rounded-full border border-edge bg-surface-card px-3 py-[5px] font-medium text-content-muted hover:border-edge-secondary hover:text-content' function IconAction({ label, onClick, danger = false, children }: { label: string; onClick: () => void; danger?: boolean; children: React.ReactNode }) { return ( ) } /** The square copy button beside a value; the tick stays for two seconds after a copy. */ function CopyButton({ copied, label, onClick }: { copied: boolean; label: string; onClick: () => void }) { return ( {copied ? : } ) } /** A value to read and copy: the boxed text and its copy button on one line. */ function CopyValue({ value, copied, label, onCopy, wrap = false }: { value: string; copied: boolean; label: string; onCopy: () => void; wrap?: boolean }) { return (
{value}
) } /** The warning box: a secret shown once, or the API tokens on their way out. */ function WarningNote({ children }: { children: React.ReactNode }) { return (

{children}

) } /** A short line in a dashed box where a list would be. */ function EmptyHint({ children }: { children: React.ReactNode }) { return (
{children}
) } /** The mcp-remote snippet, folded away until asked for. */ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: { open: boolean onToggle: () => void json: string hint: string copied: boolean onCopy: () => void t: IntegrationsState['t'] }) { return (
{open && (
            {json}
          
{hint}
)}
) } /** One registered OAuth client: what it is, the scopes it may use, and its two actions. */ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) { const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S const expanded = !!oauthScopesExpanded[client.id] return (
{client.name} {client.allows_client_credentials && ( {t('settings.oauth.badge.machine')} )}
{t('settings.oauth.clientId')}: {client.client_id} {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( {s} ))} {client.allowed_scopes.length > 5 && ( )}
setOauthRotateId(client.id)}> setOauthDeleteId(client.id)} danger>
) } /** The OAuth tab: the snippet, the registered clients and the sessions they hold. */ function OAuthTab({ S }: { S: IntegrationsState }) { const { t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy, } = S return ( <> setConfigOpenOAuth(o => !o)} json={mcpJsonConfigOAuth} hint={t('settings.mcp.clientConfigHintOAuth')} copied={copiedKey === 'json-oauth'} onCopy={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')} t={t} />
{t('settings.oauth.clientsHint')}
{oauthClients.length === 0 ? ( {t('settings.oauth.noClients')} ) : ( {oauthClients.map(client => )} )}
{/* Active OAuth Sessions */} {oauthSessions.length > 0 && (
{t('settings.oauth.activeSessions')}
{oauthSessions.map(session => (

{session.client_name}

{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')} {t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}

))}
)} ) } /** The deprecated API tokens tab: the warning, the snippet and the tokens. */ function ApiTokensTab({ S }: { S: IntegrationsState }) { const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S return ( <> {t('settings.mcp.apiTokensDeprecated')} setConfigOpenToken(o => !o)} json={mcpJsonConfig} hint={t('settings.mcp.clientConfigHint')} copied={copiedKey === 'json-token'} onCopy={() => handleCopy(mcpJsonConfig, 'json-token')} t={t} />
{mcpTokens.length === 0 ? ( {t('settings.mcp.noTokens')} ) : ( {mcpTokens.map(token => (

{token.name}

{token.token_prefix}... {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} {token.last_used_at && ( · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} )}

setMcpDeleteId(token.id)} danger>
))}
)} ) } function IntegrationsMcpSection({ S }: { S: IntegrationsState }) { const { t, activeMcpTab, setActiveMcpTab, copiedKey, mcpEndpoint, handleCopy } = S return (
{/* Endpoint URL */} handleCopy(mcpEndpoint, 'endpoint')} /> {/* Sub-tab bar */} label={t('settings.mcp.title')} value={activeMcpTab} onChange={setActiveMcpTab} fill options={[ { value: 'oauth', label: t('settings.oauth.clients') }, { value: 'apitokens', label: ( <> {t('settings.mcp.apiTokens')} Deprecated ), }, ]} /> {activeMcpTab === 'oauth' && } {activeMcpTab === 'apitokens' && }
) } /** The raised tile of the integrations' dialogs. */ function Tile({ icon: Icon }: { icon: typeof KeyRound }) { return } /** Naming a new API token, then showing it the one time it can be read. */ function CreateTokenDialog({ S }: { S: IntegrationsState }) { const { t, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, setMcpModalOpen, mcpCreating, copiedKey, handleCreateMcpToken, handleCopy } = S const labelId = useId() const cancel = () => setMcpModalOpen(false) const done = () => { setMcpModalOpen(false); setMcpCreatedToken(null) } return ( { if (!mcpCreatedToken) cancel() }} labelledBy={labelId} width="narrow" header={ } tint={NEUTRAL_TINT} labelId={labelId} onClose={mcpCreatedToken ? done : cancel} title={mcpCreatedToken ? t('settings.mcp.modal.createdTitle') : t('settings.mcp.modal.createTitle')} /> } footer={ {mcpCreatedToken ? ( {t('settings.mcp.modal.done')} ) : ( <> {t('common.cancel')} {mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')} )} } > {mcpCreatedToken ? ( <> {t('settings.mcp.modal.createdWarning')} handleCopy(mcpCreatedToken, 'new-token')} wrap /> ) : ( setMcpNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} className={INPUT} autoFocus /> )} ) } function McpTokenModals({ S }: { S: IntegrationsState }) { const { t, mcpModalOpen, mcpDeleteId, setMcpDeleteId, handleDeleteMcpToken } = S return ( <> {/* Create MCP Token modal */} {mcpModalOpen && } {/* Delete MCP Token confirm */} setMcpDeleteId(null)} onConfirm={() => { if (mcpDeleteId !== null) void handleDeleteMcpToken(mcpDeleteId) }} title={t('settings.mcp.deleteTokenTitle')} message={t('settings.mcp.deleteTokenMessage')} confirmLabel={t('settings.mcp.deleteTokenTitle')} /> ) } /** * The machine-client switch. A real checkbox under the switch's look: the form * reads it by its id, and it stays a checkbox for assistive technology. */ function MachineClientSwitch({ checked, onChange, t }: { checked: boolean; onChange: (next: boolean) => void; t: IntegrationsState['t'] }) { return ( ) } /** The register form of the client dialog. */ function ClientForm({ S, labelId }: { S: IntegrationsState; labelId: string }) { const { t, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthIsMachine, setOauthIsMachine } = S return ( <>
{OAUTH_PRESETS.map(preset => ( ))}
setOauthNewName(e.target.value)} placeholder={t('settings.oauth.modal.clientNamePlaceholder')} className={INPUT} autoFocus /> {!oauthIsMachine && (