/** * SpaceBrowserPanel.tsx — スペース「設定 → ブラウザ」パネル * * そのスペースのブラウザセッションプロファイル・マクロ・録画をまとめて * 管理する。すべて per-space スコープ: * - セッション: GET/POST/DELETE /api/browser-sessions/profiles?spaceId=… * - マクロ/録画: /api/users/me/folder/{list,file}?subdir=…&spaceId=… * * DEK は per-user のため、自分が作成していないセッション * (decryptableByViewer===false) は一覧に出るが復号・利用できない。その行には * 「作成者のみ利用可」を明示する。 * * 管理操作(追加・削除)は canEditInSpace をバックエンドが強制する。UI は * SpaceMembersPanel と同じ実シグナル(admin / 自分が owner 行)で、判定できる * ときだけ管理コントロールを出す。判定できない場合でも 403 はトーストで処理。 */ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile, listFolderFiles, getFolderFile, deleteFolderFile, fetchSpaceMembers, type BrowserSessionProfile, } from '../../api'; import { AddBrowserSessionDialog } from '../userfolder/AddBrowserSessionDialog'; import { FilePreview } from '../files/FilePreview'; import { useAuthState } from '../../App'; type ShowToast = (message: string, variant?: 'success' | 'error') => void; const STATUS_CLASS: Record = { pending: 'bg-slate-200 text-slate-700', active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', expired: 'bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300', revoked: 'bg-slate-200 text-slate-500', error: 'bg-rose-100 dark:bg-rose-500/15 text-rose-700 dark:text-rose-300', }; function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) { const { t } = useTranslation('spaces'); return ( {t(`browser.status.${status}`)} ); } function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); } export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) { const { t } = useTranslation('spaces'); const auth = useAuthState(); const qc = useQueryClient(); // 管理可否は SpaceMembersPanel と同じ実シグナルで判定する。判定できない // ときも管理コントロールは出すが、403 は mutation onError でトースト処理。 const { data: members } = useQuery({ queryKey: ['space-members', spaceId], queryFn: () => fetchSpaceMembers(spaceId), staleTime: 30_000, }); const ownerRow = (members ?? []).find(m => m.isOwner); const canManage = auth.mode === 'disabled' || (auth.mode === 'authenticated' && (auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id))); // ── セッション ────────────────────────────────────────────── const { data: profiles = [], isLoading: sessLoading } = useQuery({ queryKey: ['space-browser-sessions', spaceId], queryFn: () => listBrowserSessionProfiles(spaceId), }); const delSess = useMutation({ mutationFn: (id: number) => deleteBrowserSessionProfile(id, spaceId), onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }), onError: (e) => showToast?.(t('browser.toast.sessionDeleteFailed', { msg: errMsg(e) }), 'error'), }); const testSess = useMutation({ mutationFn: (id: number) => testBrowserSessionProfile(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }), onError: (e) => showToast?.(t('browser.toast.sessionTestFailed', { msg: errMsg(e) }), 'error'), }); const [adding, setAdding] = useState(false); // ── マクロ / 録画 ─────────────────────────────────────────── const macros = useQuery({ queryKey: ['space-browser-macros', spaceId], queryFn: () => listFolderFiles('browser-macros', spaceId), }); const recordings = useQuery({ queryKey: ['space-browser-recordings', spaceId], queryFn: () => listFolderFiles('recordings', spaceId), }); const delMacro = useMutation({ mutationFn: (name: string) => deleteFolderFile('browser-macros', name, spaceId), onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-macros', spaceId] }), onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'), }); const delRecording = useMutation({ mutationFn: (name: string) => deleteFolderFile('recordings', name, spaceId), onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-recordings', spaceId] }), onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'), }); // ファイル内容プレビュー(マクロ・録画共通)。 const [preview, setPreview] = useState<{ name: string; content: string } | null>(null); async function openPreview(subdir: 'browser-macros' | 'recordings', name: string) { try { const content = await getFolderFile(subdir, name, spaceId); setPreview({ name, content }); } catch (e) { showToast?.(t('browser.toast.contentFetchFailed', { msg: errMsg(e) }), 'error'); } } return (
{/* ── セッション ── */}

{t('browser.sessions.heading')}

{canManage && ( )}

{t('browser.sessions.intro')}

{sessLoading &&
{t('common:loading')}
}
{profiles.length === 0 && !sessLoading && (
{t('browser.sessions.empty')}
{canManage &&
{t('browser.sessions.emptyHint')}
}
)} {profiles.map(p => { const usable = p.decryptableByViewer !== false; return (
{p.label} {!usable && ( {t('browser.sessions.creatorOnly')} )}
{p.startUrl}
{p.lastError &&
{p.lastError}
} {!usable && (
{t('browser.sessions.creatorOnlyHint')}
)}
{usable && ( )} {canManage && ( )}
); })}
{/* ── マクロ ── */} openPreview('browser-macros', name)} onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delMacro.mutate(name); }} /> {/* ── 録画 ── */} openPreview('recordings', name)} onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delRecording.mutate(name); }} />
{adding && ( setAdding(false)} /> )} {preview && ( setPreview(null)} /> )}
); } interface FolderSectionProps { title: string; testid: string; subdir: 'browser-macros' | 'recordings'; query: { data?: { name: string; size: number; mtime: string }[]; isLoading: boolean }; emptyText: string; hint: string; canManage: boolean; onView: (name: string) => void; onDelete: (name: string) => void; } function FolderSection({ title, testid, query, emptyText, hint, canManage, onView, onDelete }: FolderSectionProps) { const { t } = useTranslation('spaces'); const files = query.data ?? []; return (

{title}

{hint}

{query.isLoading &&
{t('common:loading')}
}
{files.length === 0 && !query.isLoading && (
{emptyText}
)} {files.map(f => (
{canManage && ( )}
))}
); }