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, spaceId?: string): Promise { // 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 { 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): Promise { 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 { 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 { 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 { 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(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) => 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 }) => 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 (

{t('ssh.title')}

}} />
); } const owned = (data?.list ?? []).filter(c => c.ownerId !== null); const globals = (data?.list ?? []).filter(c => c.ownerId === null); return (

{t('ssh.title')}

{t('ssh.intro')}

{isLoading &&
{t('common.loading')}
} {error &&
{t('ssh.loadFailed', { error: String(error) })}
} {creating && (

{t('ssh.newHeading')}

{ await createMutation.mutateAsync(body); }} onCancel={() => setCreating(false)} />
)} {owned.length === 0 && !creating && (
{t('ssh.ownEmpty')}
)}
    {owned.map(c => ( { 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} /> ))}
{globals.length > 0 && ( <>

{t('ssh.globalNote')}

    {globals.map(c => ( {}} 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} /> ))}
)}
{testResult && ( setTestResult(null)} onVerify={(args) => handleVerifyHostKey(testResult.id, args)} /> )} {pubKeyDialog && ( setPubKeyDialog(null)} /> )}
); } /** 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 (
{title} ({count})
); } interface ConnectionRowProps { connection: SshConnection; isOwner: boolean; editing: boolean; onEdit: () => void; onCancelEdit: () => void; onPatch: (body: Record) => Promise; 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 (
  • {c.label} {disabled && {c.disabledByAdmin ? t('ssh.row.adminDisabled') : t('ssh.row.disabled')}} {c.allowRemoteUnrestricted && {t('ssh.row.remoteUnrestricted')}} {c.allowPrivateAddresses && {t('ssh.row.privateAddrs')}}
    {c.username}@{c.host}:{c.port}
    id: {' · '}path-prefix: {c.remotePathPrefix} {c.keyFingerprint && ( <> {' · '}key fp: {c.keyFingerprint.slice(0, 24)}… )}
    {c.disabledByAdminReason && (
    {t('ssh.row.reasonLabel', { reason: c.disabledByAdminReason })}
    )}
    {isOwner && !editing && ( )} {isOwner && ( )}
    {editing && (
    { await onPatch(body); }} onCancel={onCancelEdit} />
    )}
  • ); } /** * 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 ( ); } function ScopeBadge({ owner }: { owner: string | null }) { return owner === null ? ( global ) : ( personal ); } function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) { const { t } = useTranslation('userfolder'); if (pending) return {t('ssh.hostKey.pending')}; if (verified) return {t('ssh.hostKey.verified')}; return {t('ssh.hostKey.untested')}; } function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) { const cls: Record = { 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 ( {children} ); }