feat(pluginInstall): add multi-step confirmation for third-party plugin installations with security warnings

This commit is contained in:
LTbinglingfeng
2026-06-15 03:32:02 +08:00
Unverified
parent e4b872175e
commit 67d3fe6221
9 changed files with 588 additions and 33 deletions
@@ -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;
+82 -27
View File
@@ -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<string[]>([]);
const descriptionRefs = useRef<Record<string, HTMLParagraphElement | null>>({});
// Multi-step install gauntlet, shown only for non-official (third-party) plugins.
const [gateOpen, setGateOpen] = useState(false);
const [gateEntry, setGateEntry] = useState<PluginStoreEntry | null>(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() {
<span className={styles.cardId}>{entry.id}</span>
</div>
<div className={styles.cardBadges}>
{!isOfficial ? (
<span className={styles.badgeUntrusted}>
<IconAlertTriangle size={11} />
{t('plugin_store.badge_untrusted')}
</span>
) : null}
{isUpdate ? (
<span className={styles.badgeWarning}>{t('plugin_store.badge_update')}</span>
) : entry.installed ? (
@@ -449,6 +486,15 @@ export function PluginStorePage() {
<p className={styles.description}>{t('plugin_store.description')}</p>
</div>
{/* ── Security Banner ── */}
<div className={styles.securityBanner} role="note">
<IconShield size={20} />
<div className={styles.securityBannerText}>
<strong>{t('plugin_store.security_banner_title')}</strong>
<p>{t('plugin_store.security_banner_text')}</p>
</div>
</div>
{/* ── Alerts ── */}
{error ? (
<div className={styles.errorBox}>
@@ -602,6 +648,15 @@ export function PluginStorePage() {
) : (
<div className={styles.cardGrid}>{visiblePlugins.map((entry) => renderCard(entry))}</div>
)}
<PluginInstallGateModal
open={gateOpen}
entry={gateEntry}
isUpdate={gateIsUpdate}
installing={gateEntry ? installingKey === getStoreEntryKey(gateEntry) : false}
onClose={handleGateClose}
onConfirm={handleGateConfirm}
/>
</div>
);
}
@@ -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);
}
@@ -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<void>;
}
function GateLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
return src && !failed ? (
<img src={src} alt="" onError={() => setFailed(true)} />
) : (
<IconPlug size={26} />
);
}
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 = (
<div className={styles.identity}>
<div className={styles.logoBox} aria-hidden="true">
<GateLogo src={logo} />
</div>
<h3 className={styles.name}>{title}</h3>
<p className={styles.slug}>{repoSlug || entry.id}</p>
{sourceText ? (
<p className={styles.source}>{t('plugin_store.source_name', { source: sourceText })}</p>
) : null}
</div>
);
let body: ReactNode;
let footer: ReactNode;
if (step === 1) {
body = identity;
footer = (
<Button variant="secondary" fullWidth onClick={() => setStep(2)}>
{t('plugin_store.gate_step1_action')}
</Button>
);
} else if (step === 2) {
body = (
<>
{identity}
<div className={styles.warningBanner}>
<IconAlertTriangle size={18} />
<span>{t('plugin_store.gate_warning')}</span>
</div>
<ul className={styles.effects}>
<li>{t('plugin_store.gate_effect_runs_code')}</li>
<li>{t('plugin_store.gate_effect_no_review')}</li>
<li>{t('plugin_store.gate_effect_restart')}</li>
</ul>
<div className={styles.untrustedAlert}>
<p className={styles.untrustedText}>{t('plugin_store.gate_untrusted_alert')}</p>
<dl className={styles.originGrid}>
<dt>{t('plugin_store.gate_repository_label')}</dt>
<dd>{repoSlug || entry.repository || '—'}</dd>
<dt>{t('plugin_store.gate_source_label')}</dt>
<dd>{sourceText || '—'}</dd>
</dl>
</div>
</>
);
footer = (
<Button variant="secondary" fullWidth onClick={() => setStep(3)}>
{t('plugin_store.gate_step2_action')}
</Button>
);
} else {
body = (
<>
{identity}
<div className={styles.confirmBlock}>
<label className={styles.confirmPrompt} htmlFor="plugin-gate-confirm">
{t('plugin_store.gate_step3_prompt', { token })}
</label>
<Input
id="plugin-gate-confirm"
value={typed}
onChange={(event) => setTyped(event.target.value)}
autoComplete="off"
spellCheck={false}
disabled={installing}
aria-label={t('plugin_store.gate_step3_prompt', { token })}
/>
<p className={styles.confirmHint}>{t('plugin_store.gate_step3_hint')}</p>
</div>
</>
);
footer = (
<Button
variant="danger"
fullWidth
onClick={handleFinalConfirm}
disabled={!tokenMatches || installing}
loading={installing}
>
{t('plugin_store.gate_step3_action')}
</Button>
);
}
return (
<Modal
open={open}
onClose={handleClose}
title={t(isUpdate ? 'plugin_store.gate_title_update' : 'plugin_store.gate_title', {
name: title,
})}
closeDisabled={installing}
footer={footer}
width={520}
className={styles.gateModal}
>
{body}
</Modal>
);
}
+37 -1
View File
@@ -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[] =>
+18 -1
View File
@@ -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",
+18 -1
View File
@@ -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": "Информация о центре управления",
+18 -1
View File
@@ -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": "插件页面",
+18 -1
View File
@@ -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": "插件頁面",