This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from './SshConnectionForm';
|
||||
@@ -124,6 +125,7 @@ interface SshConnectionsPanelProps {
|
||||
}
|
||||
|
||||
export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}) {
|
||||
const { t } = useTranslation('userfolder');
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ssh', 'connections'],
|
||||
@@ -145,7 +147,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
onSuccess: (resp) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setCreating(false);
|
||||
showToast?.('SSH 接続を作成しました', 'success');
|
||||
showToast?.(t('ssh.toast.created'), 'success');
|
||||
// If the server returned a public key (always for keypairSource=generate;
|
||||
// also surfaced for provided keys), open the dialog so the user can
|
||||
// copy it into authorized_keys.
|
||||
@@ -167,11 +169,11 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
if (publicKey) {
|
||||
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
|
||||
} else {
|
||||
showToast?.('公開鍵の取得に失敗しました', 'error');
|
||||
showToast?.(t('ssh.toast.publicKeyFetchFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
|
||||
showToast?.(e instanceof Error ? e.message : t('ssh.toast.publicKeyFetchFailedShort'), 'error');
|
||||
},
|
||||
});
|
||||
const patchMutation = useMutation({
|
||||
@@ -179,17 +181,17 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setEditingId(null);
|
||||
showToast?.('SSH 接続を更新しました', 'success');
|
||||
showToast?.(t('ssh.toast.updated'), 'success');
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: apiDelete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('SSH 接続を削除しました', 'success');
|
||||
showToast?.(t('ssh.toast.deleted'), 'success');
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '削除失敗', 'error');
|
||||
showToast?.(e instanceof Error ? e.message : t('ssh.toast.deleteFailed'), 'error');
|
||||
},
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
@@ -198,33 +200,31 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
|
||||
if (response.verdict === 'pass') {
|
||||
showToast?.(`ホストキーは一致しています (${response.fingerprint.slice(0, 20)}…)`, 'success');
|
||||
showToast?.(t('ssh.toast.hostKeyMatch', { fingerprint: response.fingerprint.slice(0, 20) }), 'success');
|
||||
} else if (response.verdict === 'first_observe' || response.verdict === 'mismatch') {
|
||||
setTestResult({ id, test: response, replaceMode: response.verdict === 'mismatch' });
|
||||
} else if (response.verdict === 'alg_not_allowed') {
|
||||
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
|
||||
showToast?.(t('ssh.toast.algNotAllowed'), 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error');
|
||||
showToast?.(e instanceof Error ? e.message : t('ssh.toast.testFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
async function handleVerifyHostKey(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
|
||||
await apiVerifyHostKey(connId, args);
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('ホストキーを検証しました', 'success');
|
||||
showToast?.(t('ssh.toast.hostKeyVerified'), 'success');
|
||||
}
|
||||
|
||||
if (data?.sshDisabled) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-2">SSH 接続</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-2">{t('ssh.title')}</h2>
|
||||
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
|
||||
SSH サブシステムは無効です。<code className="font-mono">config.yaml</code> の{' '}
|
||||
<code className="font-mono">ssh.enabled: true</code> と環境変数{' '}
|
||||
<code className="font-mono">MCP_ENCRYPTION_KEY</code> 設定後にサーバーを再起動してください。
|
||||
<Trans t={t} i18nKey="ssh.disabled" components={{ code: <code className="font-mono" /> }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,9 +239,9 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">SSH 接続</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900">{t('ssh.title')}</h2>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
エージェントの SshExec / SshUpload / SshDownload ツールから利用される SSH 接続を管理します。
|
||||
{t('ssh.intro')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -250,16 +250,16 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
|
||||
disabled={creating}
|
||||
>
|
||||
+ 新規作成
|
||||
{t('ssh.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||
{error && <div className="text-xs text-red-500">読み込みに失敗しました: {String(error)}</div>}
|
||||
{isLoading && <div className="text-xs text-slate-400">{t('common.loading')}</div>}
|
||||
{error && <div className="text-xs text-red-500">{t('ssh.loadFailed', { error: String(error) })}</div>}
|
||||
|
||||
{creating && (
|
||||
<section className="mb-5 border border-accent/40 rounded-md bg-canvas p-4">
|
||||
<h3 className="text-xs font-semibold text-slate-700 mb-2">新規 SSH 接続</h3>
|
||||
<h3 className="text-xs font-semibold text-slate-700 mb-2">{t('ssh.newHeading')}</h3>
|
||||
<SshConnectionForm
|
||||
existing={null}
|
||||
adminContext={false}
|
||||
@@ -269,10 +269,10 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SectionHeader title="自分の接続" count={owned.length} />
|
||||
<SectionHeader title={t('ssh.ownSection')} count={owned.length} />
|
||||
{owned.length === 0 && !creating && (
|
||||
<div className="text-xs text-slate-400 px-3 py-4">
|
||||
まだ接続がありません。「+ 新規作成」から登録してください。
|
||||
{t('ssh.ownEmpty')}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-hairline mb-6">
|
||||
@@ -286,7 +286,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onPatch={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`接続 "${c.label}" を削除しますか?`)) {
|
||||
if (window.confirm(t('ssh.confirmDelete', { label: c.label }))) {
|
||||
deleteMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
@@ -300,9 +300,9 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
|
||||
|
||||
{globals.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title="グローバル接続 (info-only)" count={globals.length} />
|
||||
<SectionHeader title={t('ssh.globalSection')} count={globals.length} />
|
||||
<p className="text-2xs text-slate-500 px-3 mb-2">
|
||||
管理者が登録した共有接続です。ピース側で grant が設定されている場合のみツールから利用できます。
|
||||
{t('ssh.globalNote')}
|
||||
</p>
|
||||
<ul className="divide-y divide-hairline">
|
||||
{globals.map(c => (
|
||||
@@ -374,6 +374,7 @@ interface ConnectionRowProps {
|
||||
}
|
||||
|
||||
function ConnectionRow(props: ConnectionRowProps) {
|
||||
const { t } = useTranslation('userfolder');
|
||||
const {
|
||||
connection: c, isOwner, editing,
|
||||
onEdit, onCancelEdit, onPatch, onDelete, onTest, testing,
|
||||
@@ -408,7 +409,7 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
)}
|
||||
</div>
|
||||
{c.disabledByAdminReason && (
|
||||
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">理由: {c.disabledByAdminReason}</div>
|
||||
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">{t('ssh.row.reasonLabel', { reason: c.disabledByAdminReason })}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||
@@ -418,16 +419,16 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
disabled={testing}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'テスト中…' : 'Test'}
|
||||
{testing ? t('ssh.row.testing') : t('ssh.row.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowPublicKey}
|
||||
disabled={showingPublicKey}
|
||||
title="authorized_keys に貼る公開鍵を表示"
|
||||
title={t('ssh.row.publicKeyTitle')}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{showingPublicKey ? '取得中…' : '公開鍵'}
|
||||
{showingPublicKey ? t('ssh.row.fetching') : t('ssh.row.publicKey')}
|
||||
</button>
|
||||
{isOwner && !editing && (
|
||||
<button
|
||||
@@ -435,7 +436,7 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
onClick={onEdit}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface"
|
||||
>
|
||||
編集
|
||||
{t('ssh.row.edit')}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && (
|
||||
@@ -444,7 +445,7 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
onClick={onDelete}
|
||||
className="px-2 h-7 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50 dark:hover:bg-red-500/15"
|
||||
>
|
||||
削除
|
||||
{t('ssh.row.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,6 +470,7 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
* answered by selecting/copying without opening any sub-view.
|
||||
*/
|
||||
function CopyableUuid({ value }: { value: string }) {
|
||||
const { t } = useTranslation('userfolder');
|
||||
const [copied, setCopied] = useState(false);
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -483,10 +485,10 @@ function CopyableUuid({ value }: { value: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`クリックで UUID をコピー: ${value}`}
|
||||
title={t('ssh.copyUuid.title', { value })}
|
||||
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
|
||||
>
|
||||
{copied ? '✓ コピーしました' : value}
|
||||
{copied ? t('ssh.copyUuid.copied') : value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user