From 67d3fe62212e5364e195dbf0b4fbb298c1bf586a Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 15 Jun 2026 03:32:02 +0800 Subject: [PATCH] feat(pluginInstall): add multi-step confirmation for third-party plugin installations with security warnings --- .../plugins/PluginStorePage.module.scss | 51 ++++- src/features/plugins/PluginStorePage.tsx | 109 ++++++++--- .../PluginInstallGateModal.module.scss | 166 ++++++++++++++++ .../components/PluginInstallGateModal.tsx | 181 ++++++++++++++++++ src/features/plugins/pluginResources.ts | 38 +++- src/i18n/locales/en.json | 19 +- src/i18n/locales/ru.json | 19 +- src/i18n/locales/zh-CN.json | 19 +- src/i18n/locales/zh-TW.json | 19 +- 9 files changed, 588 insertions(+), 33 deletions(-) create mode 100644 src/features/plugins/components/PluginInstallGateModal.module.scss create mode 100644 src/features/plugins/components/PluginInstallGateModal.tsx diff --git a/src/features/plugins/PluginStorePage.module.scss b/src/features/plugins/PluginStorePage.module.scss index 1e33d21..f6113d9 100644 --- a/src/features/plugins/PluginStorePage.module.scss +++ b/src/features/plugins/PluginStorePage.module.scss @@ -73,6 +73,43 @@ color: var(--text-primary); } +// ─── Security Banner (third-party plugin risk) ────────── + +.securityBanner { + display: flex; + align-items: flex-start; + gap: $spacing-sm; + padding: 12px 14px; + border-radius: $radius-md; + border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color)); + background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 14%, var(--bg-secondary)); + color: var(--text-primary); + + > svg { + flex-shrink: 0; + margin-top: 1px; + color: var(--quota-medium-color, #e0aa14); + } +} + +.securityBannerText { + min-width: 0; + + strong { + display: block; + font-size: 14px; + font-weight: 700; + line-height: 1.4; + } + + p { + margin: 2px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + } +} + // ─── Status Bar ───────────────────────────────────────── .statusBar { @@ -323,7 +360,8 @@ .badge, .badgeSuccess, -.badgeWarning { +.badgeWarning, +.badgeUntrusted { display: inline-flex; min-height: 22px; align-items: center; @@ -352,6 +390,17 @@ color: var(--warning-color); } +.badgeUntrusted { + gap: 4px; + background: rgba($warning-color, 0.12); + border: 1px solid rgba($warning-color, 0.4); + color: var(--danger-color); + + svg { + flex-shrink: 0; + } +} + .cardDesc { display: -webkit-box; margin: 0; diff --git a/src/features/plugins/PluginStorePage.tsx b/src/features/plugins/PluginStorePage.tsx index bdb5238..8a23aab 100644 --- a/src/features/plugins/PluginStorePage.tsx +++ b/src/features/plugins/PluginStorePage.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'; import { EmptyState } from '@/components/ui/EmptyState'; import { Input } from '@/components/ui/Input'; import { + IconAlertTriangle, IconDownload, IconExternalLink, IconGithub, @@ -12,13 +13,15 @@ import { IconRefreshCw, IconSearch, IconSettings, + IconShield, } from '@/components/ui/icons'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { pluginStoreApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { getErrorMessage, isRecord } from '@/utils/helpers'; import type { PluginStoreEntry, PluginStoreResponse } from '@/types'; -import { buildRepositoryURL, resolvePluginAssetURL } from './pluginResources'; +import { buildRepositoryURL, isOfficialPlugin, resolvePluginAssetURL } from './pluginResources'; +import { PluginInstallGateModal } from './components/PluginInstallGateModal'; import styles from './PluginStorePage.module.scss'; type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates'; @@ -75,6 +78,11 @@ export function PluginStorePage() { const [overflowingDescriptionKeys, setOverflowingDescriptionKeys] = useState([]); const descriptionRefs = useRef>({}); + // Multi-step install gauntlet, shown only for non-official (third-party) plugins. + const [gateOpen, setGateOpen] = useState(false); + const [gateEntry, setGateEntry] = useState(null); + const [gateIsUpdate, setGateIsUpdate] = useState(false); + const connected = connectionStatus === 'connected'; const loadStore = useCallback(async () => { @@ -240,13 +248,49 @@ export function PluginStorePage() { ); }, []); + const runInstall = useCallback( + async (entry: PluginStoreEntry, isUpdate: boolean) => { + const entryKey = getStoreEntryKey(entry); + const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed'; + setInstallingKey(entryKey); + try { + const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined); + showNotification( + isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'), + 'success' + ); + if (result.restartRequired) { + setRestartRequiredKeys((current) => + current.includes(entryKey) ? current : [...current, entryKey] + ); + showNotification(t('plugin_store.restart_required_notice'), 'warning'); + } + clearConfigCache(); + await loadStore(); + } catch (err: unknown) { + showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error'); + throw err; + } finally { + setInstallingKey(''); + } + }, + [clearConfigCache, loadStore, showNotification, t] + ); + const handleInstall = (entry: PluginStoreEntry) => { - const entryKey = getStoreEntryKey(entry); const isUpdate = entry.installed && entry.updateAvailable; + + // Third-party plugins must clear the multi-step confirmation gauntlet first. + if (!isOfficialPlugin(entry)) { + setGateEntry(entry); + setGateIsUpdate(isUpdate); + setGateOpen(true); + return; + } + + // Official router-for-me plugins keep the lightweight single-step confirm. const title = getStoreEntryTitle(entry); const target = entry.version ? `${title} v${entry.version}` : title; - const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed'; - showConfirmation({ title: isUpdate ? t('plugin_store.update_confirm_title') @@ -256,38 +300,25 @@ export function PluginStorePage() { : t('plugin_store.install_confirm_message', { target }), confirmText: isUpdate ? t('plugin_store.update') : t('plugin_store.install'), variant: 'primary', - onConfirm: async () => { - setInstallingKey(entryKey); - try { - const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined); - showNotification( - isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'), - 'success' - ); - if (result.restartRequired) { - setRestartRequiredKeys((current) => - current.includes(entryKey) ? current : [...current, entryKey] - ); - showNotification(t('plugin_store.restart_required_notice'), 'warning'); - } - clearConfigCache(); - await loadStore(); - } catch (err: unknown) { - showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error'); - throw err; - } finally { - setInstallingKey(''); - } - }, + onConfirm: () => runInstall(entry, isUpdate), }); }; + const handleGateConfirm = useCallback(async () => { + if (!gateEntry) return; + await runInstall(gateEntry, gateIsUpdate); + setGateOpen(false); + }, [gateEntry, gateIsUpdate, runInstall]); + + const handleGateClose = useCallback(() => setGateOpen(false), []); + const renderCard = (entry: PluginStoreEntry) => { const entryKey = getStoreEntryKey(entry); const logo = resolvePluginAssetURL(entry.logo, apiBase); const repositoryURL = buildRepositoryURL(entry.repository); const homepageURL = /^https?:\/\//i.test(entry.homepage) ? entry.homepage : ''; const isUpdate = entry.installed && entry.updateAvailable; + const isOfficial = isOfficialPlugin(entry); const versionText = isUpdate && entry.installedVersion && entry.version ? t('plugin_store.version_arrow', { from: entry.installedVersion, to: entry.version }) @@ -317,6 +348,12 @@ export function PluginStorePage() { {entry.id}
+ {!isOfficial ? ( + + + {t('plugin_store.badge_untrusted')} + + ) : null} {isUpdate ? ( {t('plugin_store.badge_update')} ) : entry.installed ? ( @@ -449,6 +486,15 @@ export function PluginStorePage() {

{t('plugin_store.description')}

+ {/* ── Security Banner ── */} +
+ +
+ {t('plugin_store.security_banner_title')} +

{t('plugin_store.security_banner_text')}

+
+
+ {/* ── Alerts ── */} {error ? (
@@ -602,6 +648,15 @@ export function PluginStorePage() { ) : (
{visiblePlugins.map((entry) => renderCard(entry))}
)} + +
); } diff --git a/src/features/plugins/components/PluginInstallGateModal.module.scss b/src/features/plugins/components/PluginInstallGateModal.module.scss new file mode 100644 index 0000000..079f427 --- /dev/null +++ b/src/features/plugins/components/PluginInstallGateModal.module.scss @@ -0,0 +1,166 @@ +// Multi-step install confirmation ("gauntlet") for third-party plugins. + +.gateModal { + text-align: left; +} + +// ── Plugin identity card (shown on every step) ── +.identity { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: $spacing-sm 0 $spacing-md; + text-align: center; + border-bottom: 1px solid var(--border-color); +} + +.logoBox { + display: inline-flex; + width: 52px; + height: 52px; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent); + background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); + color: var(--text-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.name { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.slug { + margin: 0; + font-family: $font-mono; + font-size: 13px; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.source { + margin: 0; + font-size: 12px; + color: var(--text-tertiary); + overflow-wrap: anywhere; +} + +// ── Step 2: caution banner + effects + untrusted alert ── +.warningBanner { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-lg; + padding: 12px 14px; + border-radius: $radius-md; + border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color)); + background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 16%, var(--bg-secondary)); + color: var(--text-primary); + font-weight: 600; + font-size: 14px; + line-height: 1.4; + + svg { + flex-shrink: 0; + color: var(--quota-medium-color, #e0aa14); + } +} + +.effects { + margin: $spacing-md 0 0; + padding-left: $spacing-md; + border-left: 2px solid var(--border-color); + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + + li { + position: relative; + padding-left: 18px; + font-size: 14px; + line-height: 1.5; + color: var(--text-secondary); + + &::before { + content: ''; + position: absolute; + left: 0; + top: 7px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-tertiary); + } + } +} + +.untrustedAlert { + margin-top: $spacing-lg; + padding: 12px 14px; + border-radius: $radius-md; + border: 1px solid rgba($warning-color, 0.35); + background: rgba($warning-color, 0.1); +} + +.untrustedText { + margin: 0; + font-size: 13.5px; + line-height: 1.5; + font-weight: 600; + color: var(--danger-color); +} + +.originGrid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 10px 0 0; + + dt { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + } + + dd { + margin: 0; + font-family: $font-mono; + font-size: 12px; + color: var(--text-primary); + overflow-wrap: anywhere; + } +} + +// ── Step 3: type-to-confirm ── +.confirmBlock { + margin-top: $spacing-lg; +} + +.confirmPrompt { + display: block; + margin-bottom: $spacing-sm; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.5; + overflow-wrap: anywhere; +} + +.confirmHint { + margin: $spacing-sm 0 0; + font-size: 12px; + color: var(--text-tertiary); +} diff --git a/src/features/plugins/components/PluginInstallGateModal.tsx b/src/features/plugins/components/PluginInstallGateModal.tsx new file mode 100644 index 0000000..ccb0a1d --- /dev/null +++ b/src/features/plugins/components/PluginInstallGateModal.tsx @@ -0,0 +1,181 @@ +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { IconAlertTriangle, IconPlug } from '@/components/ui/icons'; +import { useAuthStore } from '@/stores'; +import type { PluginStoreEntry } from '@/types'; +import { + getPluginConfirmToken, + getPluginRepositorySlug, + resolvePluginAssetURL, +} from '../pluginResources'; +import styles from './PluginInstallGateModal.module.scss'; + +interface PluginInstallGateModalProps { + open: boolean; + entry: PluginStoreEntry | null; + isUpdate: boolean; + installing: boolean; + onClose: () => void; + onConfirm: () => void | Promise; +} + +function GateLogo({ src }: { src: string }) { + const [failed, setFailed] = useState(false); + return src && !failed ? ( + setFailed(true)} /> + ) : ( + + ); +} + +export function PluginInstallGateModal({ + open, + entry, + isUpdate, + installing, + onClose, + onConfirm, +}: PluginInstallGateModalProps) { + const { t } = useTranslation(); + const apiBase = useAuthStore((state) => state.apiBase); + const [step, setStep] = useState(1); + const [typed, setTyped] = useState(''); + const [wasOpen, setWasOpen] = useState(false); + + // Reset the gauntlet to step 1 on each fresh open. Adjusting state during render + // (React's "you might not need an effect" guidance) avoids a setState-in-effect. + if (open !== wasOpen) { + setWasOpen(open); + if (open) { + setStep(1); + setTyped(''); + } + } + + if (!entry) return null; + + const title = entry.name || entry.id; + const repoSlug = getPluginRepositorySlug(entry.repository); + const token = getPluginConfirmToken(entry); + const logo = resolvePluginAssetURL(entry.logo, apiBase); + const sourceText = entry.sourceName || entry.sourceUrl; + const tokenMatches = typed.trim() === token; + + const handleClose = () => { + if (installing) return; + onClose(); + }; + + const handleFinalConfirm = async () => { + try { + await onConfirm(); + } catch { + // The caller surfaces the error via a notification; stay on this step. + } + }; + + const identity = ( +
+ +

{title}

+

{repoSlug || entry.id}

+ {sourceText ? ( +

{t('plugin_store.source_name', { source: sourceText })}

+ ) : null} +
+ ); + + let body: ReactNode; + let footer: ReactNode; + + if (step === 1) { + body = identity; + footer = ( + + ); + } else if (step === 2) { + body = ( + <> + {identity} +
+ + {t('plugin_store.gate_warning')} +
+
    +
  • {t('plugin_store.gate_effect_runs_code')}
  • +
  • {t('plugin_store.gate_effect_no_review')}
  • +
  • {t('plugin_store.gate_effect_restart')}
  • +
+
+

{t('plugin_store.gate_untrusted_alert')}

+
+
{t('plugin_store.gate_repository_label')}
+
{repoSlug || entry.repository || '—'}
+
{t('plugin_store.gate_source_label')}
+
{sourceText || '—'}
+
+
+ + ); + footer = ( + + ); + } else { + body = ( + <> + {identity} +
+ + setTyped(event.target.value)} + autoComplete="off" + spellCheck={false} + disabled={installing} + aria-label={t('plugin_store.gate_step3_prompt', { token })} + /> +

{t('plugin_store.gate_step3_hint')}

+
+ + ); + footer = ( + + ); + } + + return ( + + {body} + + ); +} diff --git a/src/features/plugins/pluginResources.ts b/src/features/plugins/pluginResources.ts index a271448..b7b2f9b 100644 --- a/src/features/plugins/pluginResources.ts +++ b/src/features/plugins/pluginResources.ts @@ -1,4 +1,4 @@ -import type { PluginListEntry, PluginMenu } from '@/types'; +import type { PluginListEntry, PluginMenu, PluginStoreEntry } from '@/types'; import { normalizeApiBase } from '@/utils/connection'; export const PLUGIN_RESOURCES_REFRESH_EVENT = 'plugin-resources-refresh'; @@ -41,6 +41,42 @@ export const buildRepositoryURL = (repository: string) => { return `https://github.com/${trimmed.replace(/^\/+/, '')}`; }; +// The exact, fully-qualified prefix every first-party repository lives under. +// Matching the whole URL (not just the extracted owner) prevents look-alike +// hosts like "https://github.com.evil.com/router-for-me/..." from being +// mistaken for the official org. +export const OFFICIAL_PLUGIN_REPO_PREFIX = 'https://github.com/router-for-me/'; + +// Normalize an "owner/repo" slug or repository URL to a bare "owner/repo". +export const getPluginRepositorySlug = (repository: string): string => { + const trimmed = repository.trim(); + if (!trimmed) return ''; + const withoutHost = /^https?:\/\/[^/]+\/(.+)$/i.exec(trimmed)?.[1] ?? trimmed; + const [owner = '', repo = ''] = withoutHost.replace(/^\/+/, '').split('/'); + if (!owner) return ''; + return repo ? `${owner}/${repo.replace(/\.git$/i, '')}` : owner; +}; + +// A repository is official only when its canonical github.com URL sits exactly +// under the router-for-me org prefix. Slugs ("router-for-me/repo") and full URLs +// are both normalized first; anything else (other hosts, look-alike domains, +// other owners) is untrusted. +export const isOfficialRepository = (repository: string): boolean => + buildRepositoryURL(repository) + .toLowerCase() + .startsWith(OFFICIAL_PLUGIN_REPO_PREFIX); + +// A plugin is official iff its code repository sits under the router-for-me org. +// Every first-party plugin lives there, so the repository URL is the single +// source of truth — see isOfficialRepository for the exact match. +export const isOfficialPlugin = (entry: PluginStoreEntry): boolean => + isOfficialRepository(entry.repository); + +// The string a user must retype to confirm a risky install: the repo slug when +// available (most faithful to the source), otherwise the plugin id. +export const getPluginConfirmToken = (entry: PluginStoreEntry): string => + getPluginRepositorySlug(entry.repository) || entry.id; + export const collectPluginResourceEntries = ( plugins: PluginListEntry[] ): PluginResourceEntry[] => diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 98dc227..ddf2bb8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1215,7 +1215,24 @@ "no_plugins_desc": "The plugin registry did not return any plugins.", "no_matches": "No matching plugins", "no_matches_desc": "No plugins match the current search or filter.", - "clear_filters": "Clear filters" + "clear_filters": "Clear filters", + "security_banner_title": "Third-party plugins run with full backend access", + "security_banner_text": "Plugins execute code inside the proxy service and can read your credentials and traffic. Only install plugins you trust — be especially careful with any plugin not published by the official router-for-me organization.", + "badge_untrusted": "Third-party", + "gate_title": "Install {{name}}", + "gate_title_update": "Update {{name}}", + "gate_warning": "Unexpected and harmful things can happen if you don't read this.", + "gate_effect_runs_code": "This plugin runs untrusted code inside your proxy backend, with access to credentials, requests, and responses.", + "gate_effect_no_review": "It is not published or reviewed by the official router-for-me organization and may behave differently in future versions.", + "gate_effect_restart": "Installing it changes the local plugin directory and may require a service restart to take effect.", + "gate_untrusted_alert": "This plugin is not from the official router-for-me organization. Only continue if you fully trust its author and source.", + "gate_repository_label": "Repository", + "gate_source_label": "Source", + "gate_step1_action": "I want to install this plugin", + "gate_step2_action": "I have read and understand these risks", + "gate_step3_prompt": "To confirm, type \"{{token}}\" in the box below", + "gate_step3_action": "Install this plugin", + "gate_step3_hint": "Enter the exact identifier shown above to enable installation." }, "plugin_resource": { "title": "Plugin Page", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3811a06..4547599 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1202,7 +1202,24 @@ "no_plugins_desc": "Реестр плагинов не вернул ни одного плагина.", "no_matches": "Нет подходящих плагинов", "no_matches_desc": "Нет плагинов, соответствующих текущему поиску или фильтру.", - "clear_filters": "Сбросить фильтры" + "clear_filters": "Сбросить фильтры", + "security_banner_title": "Сторонние плагины работают с полным доступом к бэкенду", + "security_banner_text": "Плагины выполняют код внутри прокси-сервиса и могут читать ваши учётные данные и трафик. Устанавливайте только плагины, которым доверяете, — особенно осторожно относитесь к плагинам, не опубликованным официальной организацией router-for-me.", + "badge_untrusted": "Сторонний", + "gate_title": "Установка {{name}}", + "gate_title_update": "Обновление {{name}}", + "gate_warning": "Если вы не прочитаете это, могут произойти неожиданные и опасные последствия.", + "gate_effect_runs_code": "Этот плагин выполняет недоверенный код внутри вашего прокси-бэкенда с доступом к учётным данным, запросам и ответам.", + "gate_effect_no_review": "Он не опубликован и не проверен официальной организацией router-for-me и может вести себя иначе в будущих версиях.", + "gate_effect_restart": "Установка изменяет локальный каталог плагинов и может потребовать перезапуска сервиса.", + "gate_untrusted_alert": "Этот плагин не от официальной организации router-for-me. Продолжайте, только если полностью доверяете его автору и источнику.", + "gate_repository_label": "Репозиторий", + "gate_source_label": "Источник", + "gate_step1_action": "Я хочу установить этот плагин", + "gate_step2_action": "Я прочитал и понимаю эти риски", + "gate_step3_prompt": "Для подтверждения введите «{{token}}» в поле ниже", + "gate_step3_action": "Установить этот плагин", + "gate_step3_hint": "Введите точный идентификатор, показанный выше, чтобы включить установку." }, "system_info": { "title": "Информация о центре управления", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ecb3945..257d5c5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1215,7 +1215,24 @@ "no_plugins_desc": "插件注册表未返回任何插件。", "no_matches": "没有匹配的插件", "no_matches_desc": "当前搜索或筛选条件下没有插件。", - "clear_filters": "清除筛选" + "clear_filters": "清除筛选", + "security_banner_title": "第三方插件以后端完整权限运行", + "security_banner_text": "插件会在代理服务内部执行代码,可读取你的凭据与流量。请仅安装你信任的插件——尤其要警惕任何并非由官方组织 router-for-me 发布的插件。", + "badge_untrusted": "第三方", + "gate_title": "安装 {{name}}", + "gate_title_update": "更新 {{name}}", + "gate_warning": "如果你不阅读以下内容,可能会发生意料之外的危险后果。", + "gate_effect_runs_code": "该插件会在你的代理后端内运行不受信任的代码,并可访问凭据、请求与响应。", + "gate_effect_no_review": "它并非由官方组织 router-for-me 发布或审核,且在后续版本中行为可能发生变化。", + "gate_effect_restart": "安装会修改本地插件目录,并可能需要重启服务才能生效。", + "gate_untrusted_alert": "该插件并非来自官方组织 router-for-me。请仅在你完全信任其作者与来源时继续。", + "gate_repository_label": "仓库", + "gate_source_label": "来源", + "gate_step1_action": "我要安装此插件", + "gate_step2_action": "我已阅读并理解上述风险", + "gate_step3_prompt": "如需确认,请在下方输入框中键入 “{{token}}”", + "gate_step3_action": "安装此插件", + "gate_step3_hint": "请输入上方显示的完整标识以启用安装。" }, "plugin_resource": { "title": "插件页面", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6a53353..f6bd667 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1241,7 +1241,24 @@ "no_plugins_desc": "插件註冊表未回傳任何插件。", "no_matches": "沒有符合的插件", "no_matches_desc": "目前搜尋或篩選條件下沒有插件。", - "clear_filters": "清除篩選" + "clear_filters": "清除篩選", + "security_banner_title": "第三方插件以後端完整權限執行", + "security_banner_text": "插件會在代理服務內部執行程式碼,可讀取你的憑證與流量。請僅安裝你信任的插件——尤其要警惕任何並非由官方組織 router-for-me 發布的插件。", + "badge_untrusted": "第三方", + "gate_title": "安裝 {{name}}", + "gate_title_update": "更新 {{name}}", + "gate_warning": "如果你不閱讀以下內容,可能會發生意料之外的危險後果。", + "gate_effect_runs_code": "該插件會在你的代理後端內執行不受信任的程式碼,並可存取憑證、請求與回應。", + "gate_effect_no_review": "它並非由官方組織 router-for-me 發布或審核,且在後續版本中行為可能發生變化。", + "gate_effect_restart": "安裝會修改本機插件目錄,並可能需要重新啟動服務才能生效。", + "gate_untrusted_alert": "該插件並非來自官方組織 router-for-me。請僅在你完全信任其作者與來源時繼續。", + "gate_repository_label": "儲存庫", + "gate_source_label": "來源", + "gate_step1_action": "我要安裝此插件", + "gate_step2_action": "我已閱讀並理解上述風險", + "gate_step3_prompt": "如需確認,請在下方輸入框中鍵入 “{{token}}”", + "gate_step3_action": "安裝此插件", + "gate_step3_hint": "請輸入上方顯示的完整識別碼以啟用安裝。" }, "plugin_resource": { "title": "插件頁面",