diff --git a/frontend/src/components/modals/ManageReposModal.jsx b/frontend/src/components/modals/ManageReposModal.jsx new file mode 100644 index 00000000..2b823b32 --- /dev/null +++ b/frontend/src/components/modals/ManageReposModal.jsx @@ -0,0 +1,567 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActionIcon, + Badge, + Box, + Button, + Group, + Loader, + Modal, + NumberInput, + Stack, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import ConfirmationDialog from '../ConfirmationDialog.jsx'; +import { usePluginStore } from '../../store/plugins.jsx'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getPluginRepoSettings, + previewPluginRepo, + updatePluginRepoSettings, +} from '../../utils/pages/PluginsUtils.js'; + +export default function ManageReposModal({ opened, onClose }) { + const repos = usePluginStore((s) => s.repos); + const reposLoading = usePluginStore((s) => s.reposLoading); + const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins); + const refreshRepo = usePluginStore((s) => s.refreshRepo); + const addRepo = usePluginStore((s) => s.addRepo); + const removeRepo = usePluginStore((s) => s.removeRepo); + const updateRepo = usePluginStore((s) => s.updateRepo); + + const [refreshInterval, setRefreshInterval] = useState(6); + const [savingInterval, setSavingInterval] = useState(false); + const saveIntervalTimer = useRef(null); + + const [editingKeyRepoId, setEditingKeyRepoId] = useState(null); + const [editKeyValue, setEditKeyValue] = useState(''); + const [savingKey, setSavingKey] = useState(false); + + const [showAddRepo, setShowAddRepo] = useState(false); + const [newRepoUrl, setNewRepoUrl] = useState(''); + const [newRepoPublicKey, setNewRepoPublicKey] = useState(''); + const [addingRepo, setAddingRepo] = useState(false); + const [gpgKeyFocused, setGpgKeyFocused] = useState(false); + const [repoPreview, setRepoPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const previewTimer = useRef(null); + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const loadRepoSettings = useCallback(async () => { + const data = await getPluginRepoSettings(); + if (data) setRefreshInterval(data.refresh_interval_hours ?? 6); + }, []); + + const handleSaveInterval = useCallback((val) => { + const hours = val ?? 0; + setRefreshInterval(hours); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + saveIntervalTimer.current = setTimeout(async () => { + setSavingInterval(true); + try { + await updatePluginRepoSettings({ refresh_interval_hours: hours }); + } catch { + // Error notification handled by API layer + } finally { + setSavingInterval(false); + } + }, 800); + }, []); + + // Debounced manifest preview + const fetchPreview = useCallback((url, publicKey) => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (!url.trim() || !url.match(/^https?:\/\/.+/i)) { + setRepoPreview(null); + setPreviewLoading(false); + return; + } + setPreviewLoading(true); + previewTimer.current = setTimeout(async () => { + const result = await previewPluginRepo(url.trim(), publicKey?.trim()); + setRepoPreview(result); + setPreviewLoading(false); + }, 600); + }, []); + + const handleAddRepo = useCallback(async () => { + if (!newRepoUrl.trim()) return; + setAddingRepo(true); + try { + await addRepo({ + url: newRepoUrl.trim(), + public_key: newRepoPublicKey.trim(), + }); + setNewRepoUrl(''); + setNewRepoPublicKey(''); + setRepoPreview(null); + setShowAddRepo(false); + await fetchAvailablePlugins(); + showNotification({ + title: 'Added', + message: 'Plugin repo added', + color: 'green', + }); + } catch { + // Error notification handled by API layer + } finally { + setAddingRepo(false); + } + }, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]); + + const handleDeleteRepo = useCallback( + async (id) => { + await removeRepo(id); + setDeleteConfirmId(null); + await fetchAvailablePlugins(); + showNotification({ + title: 'Removed', + message: 'Plugin repo removed', + color: 'green', + }); + }, + [removeRepo, fetchAvailablePlugins] + ); + + const handleEditKey = useCallback((repo) => { + setEditingKeyRepoId(repo.id); + setEditKeyValue(repo.public_key || ''); + }, []); + + const handleSaveKey = useCallback(async () => { + if (editingKeyRepoId == null) return; + setSavingKey(true); + try { + await updateRepo(editingKeyRepoId, { public_key: editKeyValue }); + await refreshRepo(editingKeyRepoId); + await fetchAvailablePlugins(); + showNotification({ + title: 'Updated', + message: 'Public key updated', + color: 'green', + }); + setEditingKeyRepoId(null); + setEditKeyValue(''); + } catch { + showNotification({ + title: 'Error', + message: 'Failed to update key', + color: 'red', + }); + } finally { + setSavingKey(false); + } + }, [ + editingKeyRepoId, + editKeyValue, + updateRepo, + refreshRepo, + fetchAvailablePlugins, + ]); + + // Load settings when modal opens + useEffect(() => { + if (opened) loadRepoSettings(); + }, [opened, loadRepoSettings]); + + // Cleanup any pending timers on unmount + useEffect(() => { + return () => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + }; + }, []); + + return ( + <> + +
+ Plugin Repositories + + Add third-party plugin repositories or manage existing ones. + Manifests are fetched automatically at the configured interval. + +
+
+ + Refresh Interval + + + + Hours, 0 to disable + +
+ + } + centered + size="lg" + styles={{ + title: { width: '100%' }, + header: { alignItems: 'flex-start' }, + }} + > + + {reposLoading && repos.length === 0 && } + + {repos.map((repo) => ( + + + + + + {repo.name} + + {repo.is_official && ( + + Official Repo + + )} + {repo.signature_verified === true && ( + } + > + Verified Signature + + )} + {repo.signature_verified === false && ( + } + > + Invalid Signature + + )} + + {repo.registry_url ? ( + + + {repo.registry_url} + + + ) : null} + + {repo.url} + + {repo.last_fetched && ( + + Last fetched:{' '} + {new Date(repo.last_fetched).toLocaleString()} + {repo.last_fetch_status && + repo.last_fetch_status !== '200' + ? ` · ${repo.last_fetch_status}` + : repo.plugin_count != null + ? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available` + : ''} + + )} + + {!repo.is_official && ( + + handleEditKey(repo)} + > + + + setDeleteConfirmId(repo.id)} + > + + + + )} + + {editingKeyRepoId === repo.id && ( + +