540 lines
20 KiB
TypeScript
540 lines
20 KiB
TypeScript
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';
|
|
import { SshHostKeyDialog } from './SshHostKeyDialog';
|
|
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
|
|
|
|
interface ConnectionsResponse {
|
|
connections: SshConnection[];
|
|
}
|
|
|
|
interface CreateResponse {
|
|
connection: SshConnection;
|
|
publicKey?: string | null;
|
|
}
|
|
|
|
async function fetchConnections(spaceId?: string): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
|
// spaceId 指定時はそのスペースの接続だけを一覧(バックエンドが可視性を検証)。
|
|
// 未指定なら従来どおりユーザー所有+global を返す。
|
|
const url = spaceId
|
|
? `/api/ssh/connections?spaceId=${encodeURIComponent(spaceId)}`
|
|
: '/api/ssh/connections';
|
|
const res = await fetch(url, { credentials: 'include' });
|
|
if (res.status === 404) {
|
|
return { list: [], sshDisabled: true };
|
|
}
|
|
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
|
const data: ConnectionsResponse = await res.json();
|
|
return { list: data.connections ?? [], sshDisabled: false };
|
|
}
|
|
|
|
async function apiCreate(body: Record<string, unknown>, spaceId?: string): Promise<CreateResponse> {
|
|
// spaceId 指定時は space_id を載せてそのスペース所属の接続として作成する。
|
|
const payload = spaceId ? { ...body, space_id: spaceId } : body;
|
|
const res = await fetch('/api/ssh/connections', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
return (await res.json()) as CreateResponse;
|
|
}
|
|
|
|
async function apiGetPublicKey(id: string): Promise<string | null> {
|
|
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
const json = (await res.json()) as { publicKey?: string | null };
|
|
return json.publicKey ?? null;
|
|
}
|
|
|
|
async function apiPatch(id: string, body: Record<string, unknown>): Promise<SshConnection> {
|
|
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
const json = (await res.json()) as { connection: SshConnection };
|
|
return json.connection;
|
|
}
|
|
|
|
async function apiDelete(id: string): Promise<void> {
|
|
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
}
|
|
|
|
async function apiTest(id: string): Promise<TestResponse> {
|
|
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/test`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
return (await res.json()) as TestResponse;
|
|
}
|
|
|
|
async function apiVerifyHostKey(
|
|
id: string,
|
|
body: { fingerprint: string; token: string; reason?: string },
|
|
): Promise<void> {
|
|
const endpoint = body.reason ? 'replace-host-key' : 'verify-host-key';
|
|
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/${endpoint}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const txt = await res.text();
|
|
throw new Error(parseApiError(txt, res.status));
|
|
}
|
|
}
|
|
|
|
function parseApiError(rawText: string, status: number): string {
|
|
try {
|
|
const parsed = JSON.parse(rawText);
|
|
if (parsed?.error) {
|
|
return parsed.detail ? `${parsed.error}: ${typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)}` : parsed.error;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return `HTTP ${status}`;
|
|
}
|
|
|
|
interface SshConnectionsPanelProps {
|
|
/** Render personal+globals (user mode) or only globals via admin endpoints. */
|
|
scope?: 'user';
|
|
/** スペース内で表示する場合の space id。指定時は一覧/作成がそのスペースに紐づく。 */
|
|
spaceId?: string;
|
|
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
|
}
|
|
|
|
export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelProps = {}) {
|
|
const { t } = useTranslation('userfolder');
|
|
const qc = useQueryClient();
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['ssh', 'connections', spaceId ?? null],
|
|
queryFn: () => fetchConnections(spaceId),
|
|
staleTime: 15_000,
|
|
});
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
|
|
const [pubKeyDialog, setPubKeyDialog] = useState<{
|
|
publicKey: string;
|
|
label?: string;
|
|
freshlyGenerated: boolean;
|
|
} | null>(null);
|
|
|
|
const invalidateConnections = () =>
|
|
qc.invalidateQueries({ queryKey: ['ssh', 'connections', spaceId ?? null] });
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) => apiCreate(body, spaceId),
|
|
onSuccess: (resp) => {
|
|
invalidateConnections();
|
|
setCreating(false);
|
|
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.
|
|
if (resp.publicKey) {
|
|
setPubKeyDialog({
|
|
publicKey: resp.publicKey,
|
|
label: resp.connection.label,
|
|
freshlyGenerated: true,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
const showPubKeyMutation = useMutation({
|
|
mutationFn: async ({ id, label }: { id: string; label: string }) => {
|
|
const pk = await apiGetPublicKey(id);
|
|
return { publicKey: pk, label };
|
|
},
|
|
onSuccess: ({ publicKey, label }) => {
|
|
if (publicKey) {
|
|
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
|
|
} else {
|
|
showToast?.(t('ssh.toast.publicKeyFetchFailed'), 'error');
|
|
}
|
|
},
|
|
onError: (e) => {
|
|
showToast?.(e instanceof Error ? e.message : t('ssh.toast.publicKeyFetchFailedShort'), 'error');
|
|
},
|
|
});
|
|
const patchMutation = useMutation({
|
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => apiPatch(id, body),
|
|
onSuccess: () => {
|
|
invalidateConnections();
|
|
setEditingId(null);
|
|
showToast?.(t('ssh.toast.updated'), 'success');
|
|
},
|
|
});
|
|
const deleteMutation = useMutation({
|
|
mutationFn: apiDelete,
|
|
onSuccess: () => {
|
|
invalidateConnections();
|
|
showToast?.(t('ssh.toast.deleted'), 'success');
|
|
},
|
|
onError: (e) => {
|
|
showToast?.(e instanceof Error ? e.message : t('ssh.toast.deleteFailed'), 'error');
|
|
},
|
|
});
|
|
const testMutation = useMutation({
|
|
mutationFn: apiTest,
|
|
onSuccess: (response, id) => {
|
|
invalidateConnections();
|
|
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
|
|
if (response.verdict === 'pass') {
|
|
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?.(t('ssh.toast.algNotAllowed'), 'error');
|
|
}
|
|
},
|
|
onError: (e) => {
|
|
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?.(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">{t('ssh.title')}</h2>
|
|
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
|
|
<Trans t={t} i18nKey="ssh.disabled" components={{ code: <code className="font-mono" /> }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const owned = (data?.list ?? []).filter(c => c.ownerId !== null);
|
|
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto" data-testid={spaceId ? 'space-ssh-panel' : undefined}>
|
|
<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">{t('ssh.title')}</h2>
|
|
<p className="text-2xs text-slate-500 mt-0.5">
|
|
{t('ssh.intro')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setCreating(true); setEditingId(null); }}
|
|
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">{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">{t('ssh.newHeading')}</h3>
|
|
<SshConnectionForm
|
|
existing={null}
|
|
adminContext={false}
|
|
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
|
|
onCancel={() => setCreating(false)}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
<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"
|
|
data-testid={spaceId ? 'space-ssh-list' : undefined}
|
|
>
|
|
{owned.map(c => (
|
|
<ConnectionRow
|
|
key={c.id}
|
|
connection={c}
|
|
isOwner
|
|
editing={editingId === c.id}
|
|
onEdit={() => { setEditingId(c.id); setCreating(false); }}
|
|
onCancelEdit={() => setEditingId(null)}
|
|
onPatch={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
|
|
onDelete={() => {
|
|
if (window.confirm(t('ssh.confirmDelete', { label: c.label }))) {
|
|
deleteMutation.mutate(c.id);
|
|
}
|
|
}}
|
|
onTest={() => testMutation.mutate(c.id)}
|
|
testing={testMutation.isPending && testMutation.variables === c.id}
|
|
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
|
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
|
/>
|
|
))}
|
|
</ul>
|
|
|
|
{globals.length > 0 && (
|
|
<>
|
|
<SectionHeader title={t('ssh.globalSection')} count={globals.length} />
|
|
<p className="text-2xs text-slate-500 px-3 mb-2">
|
|
{t('ssh.globalNote')}
|
|
</p>
|
|
<ul className="divide-y divide-hairline">
|
|
{globals.map(c => (
|
|
<ConnectionRow
|
|
key={c.id}
|
|
connection={c}
|
|
isOwner={false}
|
|
editing={false}
|
|
onEdit={() => {}}
|
|
onCancelEdit={() => {}}
|
|
onPatch={async () => {}}
|
|
onDelete={() => {}}
|
|
onTest={() => testMutation.mutate(c.id)}
|
|
testing={testMutation.isPending && testMutation.variables === c.id}
|
|
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
|
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{testResult && (
|
|
<SshHostKeyDialog
|
|
test={testResult.test}
|
|
replaceMode={testResult.replaceMode}
|
|
onClose={() => setTestResult(null)}
|
|
onVerify={(args) => handleVerifyHostKey(testResult.id, args)}
|
|
/>
|
|
)}
|
|
|
|
{pubKeyDialog && (
|
|
<SshPublicKeyDialog
|
|
publicKey={pubKeyDialog.publicKey}
|
|
label={pubKeyDialog.label}
|
|
freshlyGenerated={pubKeyDialog.freshlyGenerated}
|
|
onClose={() => setPubKeyDialog(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Re-exported for use by other panels that render ConnectionRow. */
|
|
export type ShowPublicKeyHandler = (args: { id: string; label: string }) => void;
|
|
|
|
function SectionHeader({ title, count }: { title: string; count: number }) {
|
|
return (
|
|
<div className="flex items-center gap-2 px-1 py-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">
|
|
<span>{title}</span>
|
|
<span className="text-slate-400 font-mono">({count})</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ConnectionRowProps {
|
|
connection: SshConnection;
|
|
isOwner: boolean;
|
|
editing: boolean;
|
|
onEdit: () => void;
|
|
onCancelEdit: () => void;
|
|
onPatch: (body: Record<string, unknown>) => Promise<void>;
|
|
onDelete: () => void;
|
|
onTest: () => void;
|
|
testing: boolean;
|
|
onShowPublicKey: () => void;
|
|
showingPublicKey: boolean;
|
|
}
|
|
|
|
function ConnectionRow(props: ConnectionRowProps) {
|
|
const { t } = useTranslation('userfolder');
|
|
const {
|
|
connection: c, isOwner, editing,
|
|
onEdit, onCancelEdit, onPatch, onDelete, onTest, testing,
|
|
onShowPublicKey, showingPublicKey,
|
|
} = props;
|
|
const verified = c.hostKeyVerifiedAt !== null;
|
|
const pending = c.hostKeyPending;
|
|
const disabled = c.disabledByAdmin || !c.enabled;
|
|
|
|
return (
|
|
<li className="py-3" data-connection-id={c.id}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
|
|
<ScopeBadge owner={c.ownerId} />
|
|
<HostKeyBadge verified={verified} pending={pending} />
|
|
{disabled && <Badge color="red">{c.disabledByAdmin ? t('ssh.row.adminDisabled') : t('ssh.row.disabled')}</Badge>}
|
|
{c.allowRemoteUnrestricted && <Badge color="amber">{t('ssh.row.remoteUnrestricted')}</Badge>}
|
|
{c.allowPrivateAddresses && <Badge color="amber">{t('ssh.row.privateAddrs')}</Badge>}
|
|
</div>
|
|
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
|
|
{c.username}@{c.host}:{c.port}
|
|
</div>
|
|
<div className="text-2xs text-slate-500 mt-0.5">
|
|
id: <CopyableUuid value={c.id} />
|
|
{' · '}path-prefix: <span className="font-mono">{c.remotePathPrefix}</span>
|
|
{c.keyFingerprint && (
|
|
<>
|
|
{' · '}key fp: <span className="font-mono">{c.keyFingerprint.slice(0, 24)}…</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{c.disabledByAdminReason && (
|
|
<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">
|
|
<button
|
|
type="button"
|
|
onClick={onTest}
|
|
disabled={testing}
|
|
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
|
>
|
|
{testing ? t('ssh.row.testing') : t('ssh.row.test')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onShowPublicKey}
|
|
disabled={showingPublicKey}
|
|
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 ? t('ssh.row.fetching') : t('ssh.row.publicKey')}
|
|
</button>
|
|
{isOwner && !editing && (
|
|
<button
|
|
type="button"
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
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>
|
|
</div>
|
|
{editing && (
|
|
<div className="mt-3 ml-1 pl-3 border-l-2 border-accent/30">
|
|
<SshConnectionForm
|
|
existing={c}
|
|
adminContext={false}
|
|
onSubmit={async (body) => { await onPatch(body); }}
|
|
onCancel={onCancelEdit}
|
|
/>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Display a connection UUID with a click-to-copy action. The full UUID is
|
|
* shown inline so agents that ask "give me the connection_id" can be
|
|
* 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 {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
// Clipboard API can fail in non-secure contexts; user can still select manually.
|
|
}
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={copy}
|
|
title={t('ssh.copyUuid.title', { value })}
|
|
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
|
|
>
|
|
{copied ? t('ssh.copyUuid.copied') : value}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ScopeBadge({ owner }: { owner: string | null }) {
|
|
return owner === null ? (
|
|
<Badge color="slate">global</Badge>
|
|
) : (
|
|
<Badge color="blue">personal</Badge>
|
|
);
|
|
}
|
|
|
|
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
|
|
const { t } = useTranslation('userfolder');
|
|
if (pending) return <Badge color="amber">{t('ssh.hostKey.pending')}</Badge>;
|
|
if (verified) return <Badge color="emerald">{t('ssh.hostKey.verified')}</Badge>;
|
|
return <Badge color="slate">{t('ssh.hostKey.untested')}</Badge>;
|
|
}
|
|
|
|
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
|
|
const cls: Record<typeof color, string> = {
|
|
slate: 'bg-slate-100 text-slate-600',
|
|
blue: 'bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
|
emerald: 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
|
amber: 'bg-amber-50 dark:bg-amber-500/15 text-amber-700 dark:text-amber-300',
|
|
red: 'bg-red-50 dark:bg-red-500/15 text-red-700 dark:text-red-300',
|
|
};
|
|
return (
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls[color]}`}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|