|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useRef, useState } from "react"; |
| 4 | + |
| 5 | +import { CheckCircle } from "lucide-react"; |
| 6 | + |
| 7 | +import ConfirmModal from "@components/ui/confirm-modal"; |
| 8 | +import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui"; |
| 9 | +import { logger } from "@lib/logger"; |
| 10 | +import { api } from "@/trpc/react"; |
| 11 | + |
| 12 | +export default function DataSettingsPage() { |
| 13 | + const [restoreFile, setRestoreFile] = useState<File | null>(null); |
| 14 | + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); |
| 15 | + const [showClearConfirm, setShowClearConfirm] = useState(false); |
| 16 | + const [toastMessage, setToastMessage] = useState<string | null>(null); |
| 17 | + const [restoreError, setRestoreError] = useState<string | null>(null); |
| 18 | + const fileInputRef = useRef<HTMLInputElement | null>(null); |
| 19 | + |
| 20 | + const resetRestoreSelection = () => { |
| 21 | + setRestoreFile(null); |
| 22 | + if (fileInputRef.current) { |
| 23 | + fileInputRef.current.value = ""; |
| 24 | + } |
| 25 | + }; |
| 26 | + |
| 27 | + useEffect(() => { |
| 28 | + if (!toastMessage) return; |
| 29 | + |
| 30 | + const timeout = window.setTimeout(() => setToastMessage(null), 4000); |
| 31 | + |
| 32 | + return () => window.clearTimeout(timeout); |
| 33 | + }, [toastMessage]); |
| 34 | + |
| 35 | + const utils = api.useUtils(); |
| 36 | + const { data: stats, refetch: refetchStats } = api.data.getStats.useQuery(); |
| 37 | + |
| 38 | + const backupMutation = api.data.backup.useMutation({ |
| 39 | + onSuccess: (data) => { |
| 40 | + const blob = new Blob([data], { type: "application/json" }); |
| 41 | + const url = URL.createObjectURL(blob); |
| 42 | + const a = document.createElement("a"); |
| 43 | + a.href = url; |
| 44 | + a.download = `ttpx-data-${new Date().toISOString().split("T")[0]}.json`; |
| 45 | + document.body.appendChild(a); |
| 46 | + a.click(); |
| 47 | + document.body.removeChild(a); |
| 48 | + URL.revokeObjectURL(url); |
| 49 | + }, |
| 50 | + }); |
| 51 | + |
| 52 | + const restoreMutation = api.data.restore.useMutation({ |
| 53 | + onSuccess: () => { |
| 54 | + void refetchStats(); |
| 55 | + void utils.invalidate(); |
| 56 | + resetRestoreSelection(); |
| 57 | + setShowRestoreConfirm(false); |
| 58 | + setToastMessage("Operations and taxonomy data have been imported."); |
| 59 | + setRestoreError(null); |
| 60 | + }, |
| 61 | + onError: (error) => { |
| 62 | + setShowRestoreConfirm(false); |
| 63 | + setRestoreError(error.message); |
| 64 | + resetRestoreSelection(); |
| 65 | + }, |
| 66 | + }); |
| 67 | + |
| 68 | + const clearDataMutation = api.data.clearData.useMutation({ |
| 69 | + onSuccess: () => { |
| 70 | + void refetchStats(); |
| 71 | + void utils.invalidate(); |
| 72 | + setShowClearConfirm(false); |
| 73 | + setToastMessage("Operations and taxonomy data have been cleared."); |
| 74 | + }, |
| 75 | + onError: () => { |
| 76 | + setShowClearConfirm(false); |
| 77 | + }, |
| 78 | + }); |
| 79 | + |
| 80 | + const handleRestore = async () => { |
| 81 | + if (!restoreFile) return; |
| 82 | + |
| 83 | + try { |
| 84 | + const text = await restoreFile.text(); |
| 85 | + setRestoreError(null); |
| 86 | + restoreMutation.mutate({ backupData: text }); |
| 87 | + } catch (error) { |
| 88 | + logger.error("Failed to read backup file", error); |
| 89 | + } |
| 90 | + }; |
| 91 | + |
| 92 | + return ( |
| 93 | + <div className="space-y-6"> |
| 94 | + {toastMessage && ( |
| 95 | + <div className="fixed bottom-6 right-6 z-50"> |
| 96 | + <div |
| 97 | + role="status" |
| 98 | + aria-live="polite" |
| 99 | + className="flex items-start space-x-3 rounded-[var(--radius-lg)] border border-[var(--color-border-light)] bg-[var(--color-surface-elevated)] px-4 py-3 shadow-[var(--shadow-lg)]" |
| 100 | + > |
| 101 | + <CheckCircle className="h-5 w-5 text-[var(--status-success-fg)]" aria-hidden /> |
| 102 | + <div> |
| 103 | + <p className="text-sm font-medium text-[var(--color-text-primary)]">Success</p> |
| 104 | + <p className="text-xs text-[var(--color-text-secondary)]">{toastMessage}</p> |
| 105 | + </div> |
| 106 | + </div> |
| 107 | + </div> |
| 108 | + )} |
| 109 | + |
| 110 | + <div> |
| 111 | + <h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Data Management</h1> |
| 112 | + <p className="mt-1 text-sm text-[var(--color-text-secondary)]"> |
| 113 | + Export, import, or clear operations and taxonomy data. |
| 114 | + </p> |
| 115 | + </div> |
| 116 | + |
| 117 | + {showRestoreConfirm && ( |
| 118 | + <ConfirmModal |
| 119 | + open |
| 120 | + title="Import data from backup?" |
| 121 | + description="This will replace all existing operations and taxonomy data with the selected backup. All operations will be made visible to every user because group assignments are not restored. This action cannot be undone." |
| 122 | + confirmLabel="Import" |
| 123 | + cancelLabel="Cancel" |
| 124 | + onConfirm={handleRestore} |
| 125 | + onCancel={() => { |
| 126 | + resetRestoreSelection(); |
| 127 | + setShowRestoreConfirm(false); |
| 128 | + }} |
| 129 | + loading={restoreMutation.isPending} |
| 130 | + /> |
| 131 | + )} |
| 132 | + |
| 133 | + <Card> |
| 134 | + <CardHeader> |
| 135 | + <CardTitle>Data Overview</CardTitle> |
| 136 | + </CardHeader> |
| 137 | + <CardContent> |
| 138 | + {stats ? ( |
| 139 | + <div className="grid gap-4 grid-cols-2 md:grid-cols-4"> |
| 140 | + {[ |
| 141 | + { label: "Operations", value: stats.operations }, |
| 142 | + { label: "Techniques", value: stats.techniques }, |
| 143 | + { label: "Outcomes", value: stats.outcomes }, |
| 144 | + { label: "Threat Actors", value: stats.threatActors }, |
| 145 | + { label: "Crown Jewels", value: stats.crownJewels }, |
| 146 | + { label: "Tags", value: stats.tags }, |
| 147 | + { label: "Tools", value: stats.tools }, |
| 148 | + { label: "Log Sources", value: stats.logSources }, |
| 149 | + ].map(({ label, value }) => ( |
| 150 | + <div key={label} className="text-center"> |
| 151 | + <div className="text-2xl font-bold text-[var(--color-accent)]">{value}</div> |
| 152 | + <div className="text-sm text-[var(--color-text-secondary)]">{label}</div> |
| 153 | + </div> |
| 154 | + ))} |
| 155 | + </div> |
| 156 | + ) : ( |
| 157 | + <div className="py-4 text-center text-[var(--color-text-secondary)]">Loading statistics...</div> |
| 158 | + )} |
| 159 | + </CardContent> |
| 160 | + </Card> |
| 161 | + |
| 162 | + <div className="grid gap-6 md:grid-cols-3"> |
| 163 | + <Card> |
| 164 | + <CardHeader> |
| 165 | + <CardTitle>Export Data</CardTitle> |
| 166 | + </CardHeader> |
| 167 | + <CardContent className="space-y-4"> |
| 168 | + <p className="text-sm text-[var(--color-text-secondary)]"> |
| 169 | + Download a JSON file containing all operations and taxonomy records. |
| 170 | + </p> |
| 171 | + <Button |
| 172 | + variant="secondary" |
| 173 | + size="sm" |
| 174 | + onClick={() => backupMutation.mutate()} |
| 175 | + disabled={backupMutation.isPending} |
| 176 | + > |
| 177 | + {backupMutation.isPending ? "Preparing export..." : "Download data"} |
| 178 | + </Button> |
| 179 | + </CardContent> |
| 180 | + </Card> |
| 181 | + |
| 182 | + <Card> |
| 183 | + <CardHeader> |
| 184 | + <CardTitle>Import Data</CardTitle> |
| 185 | + </CardHeader> |
| 186 | + <CardContent className="space-y-4"> |
| 187 | + <p className="text-sm text-[var(--color-text-secondary)]"> |
| 188 | + Import a backup to replace the current operations and taxonomy data set. |
| 189 | + </p> |
| 190 | + <input |
| 191 | + ref={fileInputRef} |
| 192 | + type="file" |
| 193 | + accept=".json" |
| 194 | + className="hidden" |
| 195 | + onChange={(event) => { |
| 196 | + const file = event.target.files?.[0] ?? null; |
| 197 | + |
| 198 | + if (!file) { |
| 199 | + resetRestoreSelection(); |
| 200 | + setShowRestoreConfirm(false); |
| 201 | + return; |
| 202 | + } |
| 203 | + |
| 204 | + setRestoreFile(file); |
| 205 | + setRestoreError(null); |
| 206 | + setShowRestoreConfirm(true); |
| 207 | + event.target.value = ""; |
| 208 | + }} |
| 209 | + /> |
| 210 | + <Button |
| 211 | + variant="secondary" |
| 212 | + size="sm" |
| 213 | + onClick={() => fileInputRef.current?.click()} |
| 214 | + disabled={restoreMutation.isPending} |
| 215 | + > |
| 216 | + {restoreMutation.isPending ? "Importing..." : "Import data"} |
| 217 | + </Button> |
| 218 | + {restoreError && <div className="text-sm text-[var(--color-error)]">{restoreError}</div>} |
| 219 | + </CardContent> |
| 220 | + </Card> |
| 221 | + |
| 222 | + <Card> |
| 223 | + <CardHeader> |
| 224 | + <CardTitle className="text-[var(--color-error)]">⚠️ Clear Data</CardTitle> |
| 225 | + </CardHeader> |
| 226 | + <CardContent className="space-y-4"> |
| 227 | + <p className="text-sm text-[var(--color-text-secondary)]"> |
| 228 | + Permanently delete all operations and taxonomy data. This cannot be undone. |
| 229 | + </p> |
| 230 | + <Button |
| 231 | + variant="danger" |
| 232 | + size="sm" |
| 233 | + onClick={() => setShowClearConfirm(true)} |
| 234 | + disabled={clearDataMutation.isPending} |
| 235 | + > |
| 236 | + {clearDataMutation.isPending ? "Clearing..." : "Clear all data"} |
| 237 | + </Button> |
| 238 | + {clearDataMutation.error && ( |
| 239 | + <div className="text-sm text-[var(--color-error)]">{clearDataMutation.error.message}</div> |
| 240 | + )} |
| 241 | + </CardContent> |
| 242 | + </Card> |
| 243 | + </div> |
| 244 | + |
| 245 | + {showClearConfirm && ( |
| 246 | + <ConfirmModal |
| 247 | + open |
| 248 | + title="Delete all data?" |
| 249 | + description="This will permanently delete all operations and taxonomy data. This action cannot be undone." |
| 250 | + confirmLabel="Delete" |
| 251 | + cancelLabel="Cancel" |
| 252 | + onConfirm={() => clearDataMutation.mutate()} |
| 253 | + onCancel={() => setShowClearConfirm(false)} |
| 254 | + loading={clearDataMutation.isPending} |
| 255 | + /> |
| 256 | + )} |
| 257 | + </div> |
| 258 | + ); |
| 259 | +} |
0 commit comments