fix(qa): 5 bug fixes — audit dates, CSP, template modal, MITRE sync timeout, data source auto-sync
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- audit_service: set timestamp=datetime.now(utc) explicitly so DB never stores NULL - AuditLogPage: formatDate handles null/undefined timestamps (was showing Jan 1 1970) - nginx.conf: add CSP script-src hash for inline script (sha256-31OgE8E9...) - system.py: MITRE sync now runs in BackgroundTasks — returns immediately, no more 120s timeout - mitre_sync_job.py: add _run_data_sources_sync job (every 6h) that checks sync_frequency and auto-syncs overdue enabled data sources - SystemPage: MITRE sync result shows "started" vs "complete" message - test-templates.ts: add updateTemplate() API function - SystemPage: template name cell is now clickable — opens TemplateDetailModal with full edit form (name, description, procedure, detection, platform, severity, tool) and Save / Activate / Deactivate / Close buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ sessions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
@@ -124,6 +125,61 @@ def _run_osint_enrichment() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
_FREQUENCY_INTERVALS: dict[str, timedelta] = {
|
||||
"daily": timedelta(days=1),
|
||||
"weekly": timedelta(weeks=1),
|
||||
"monthly": timedelta(days=30),
|
||||
}
|
||||
|
||||
|
||||
def _run_data_sources_sync() -> None:
|
||||
"""Check all enabled data sources and sync those that are overdue."""
|
||||
logger.info("Scheduled data sources sync check starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.data_source import DataSource
|
||||
from app.services.data_source_service import sync_source
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sources = (
|
||||
db.query(DataSource)
|
||||
.filter(DataSource.is_enabled == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
synced = 0
|
||||
for ds in sources:
|
||||
freq = ds.sync_frequency
|
||||
if not freq or freq == "manual":
|
||||
continue
|
||||
interval = _FREQUENCY_INTERVALS.get(freq)
|
||||
if interval is None:
|
||||
continue
|
||||
last = ds.last_sync_at
|
||||
if last is None:
|
||||
# Never synced — run it now
|
||||
overdue = True
|
||||
else:
|
||||
# Make last timezone-aware if needed
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
overdue = now - last >= interval
|
||||
if overdue:
|
||||
logger.info(
|
||||
"Data source '%s' is overdue (freq=%s, last=%s) — syncing",
|
||||
ds.name, freq, last,
|
||||
)
|
||||
try:
|
||||
sync_source(db, str(ds.id))
|
||||
synced += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to sync data source '%s'", ds.name)
|
||||
logger.info("Data sources sync check finished — %d source(s) synced", synced)
|
||||
except Exception:
|
||||
logger.exception("Data sources sync check failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_stale_detection() -> None:
|
||||
"""Execute daily stale coverage detection inside its own DB session."""
|
||||
logger.info("Scheduled stale coverage detection starting...")
|
||||
@@ -226,11 +282,19 @@ def start_scheduler() -> None:
|
||||
name="Data retention policies (daily)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_data_sources_sync,
|
||||
trigger="interval",
|
||||
hours=6,
|
||||
id="data_sources_sync",
|
||||
name="Data sources auto-sync (every 6h)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info(
|
||||
"Background scheduler started — mitre_sync (24h), intel_scan (7d), "
|
||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||
"recurring_campaigns (daily), jira_sync (1h), "
|
||||
"osint_enrichment (weekly), stale_detection (daily), "
|
||||
"retention_policies (daily)"
|
||||
"retention_policies (daily), data_sources_sync (6h)"
|
||||
)
|
||||
|
||||
@@ -7,10 +7,10 @@ scheduler health introspection.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.dependencies.auth import require_role
|
||||
from app.models.user import User
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
@@ -24,25 +24,39 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
def _bg_mitre_sync() -> None:
|
||||
"""Run MITRE sync in a background task with its own DB session."""
|
||||
logger.info("Background MITRE sync task starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
summary = sync_mitre(db)
|
||||
logger.info("Background MITRE sync task finished — %s", summary)
|
||||
except Exception:
|
||||
logger.exception("Background MITRE sync task failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/sync-mitre")
|
||||
@limiter.limit("2/hour")
|
||||
def trigger_mitre_sync(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Manually trigger a MITRE ATT&CK synchronisation.
|
||||
"""Manually trigger a MITRE ATT&CK synchronisation in the background.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
|
||||
Returns a JSON object with the sync summary including the count of
|
||||
new and updated techniques.
|
||||
Returns immediately — the sync runs asynchronously. Poll
|
||||
``/system/scheduler-status`` for progress, or check server logs.
|
||||
"""
|
||||
summary = sync_mitre(db)
|
||||
background_tasks.add_task(_bg_mitre_sync)
|
||||
return {
|
||||
"message": "MITRE sync completed",
|
||||
"new": summary["created"],
|
||||
"updated": summary["updated"],
|
||||
"message": "MITRE sync started in background",
|
||||
"status": "started",
|
||||
"new": 0,
|
||||
"updated": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ def log_action(
|
||||
ip_address=ip or None,
|
||||
user_agent=ua or None,
|
||||
session_id=session_id,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(entry)
|
||||
db.flush()
|
||||
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# CSP: allow self + inline styles (React build) + data: URIs for fonts/images
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-31OgE8E9uFi947Hj0TYz0o9NSyrQOewgXrj1ZPfYDaY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
# Hide Nginx version
|
||||
server_tokens off;
|
||||
|
||||
@@ -2,8 +2,9 @@ import client from "./client";
|
||||
|
||||
export interface SyncMitreResponse {
|
||||
message: string;
|
||||
new: number;
|
||||
updated: number;
|
||||
status?: string;
|
||||
new?: number;
|
||||
updated?: number;
|
||||
}
|
||||
|
||||
export interface IntelScanResponse {
|
||||
|
||||
@@ -98,6 +98,17 @@ export async function createTemplate(
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Update (admin) ────────────────────────────────────────────────
|
||||
|
||||
/** Update fields of an existing test template. Admin/lead only. */
|
||||
export async function updateTemplate(
|
||||
id: string,
|
||||
payload: Partial<CreateTemplatePayload>,
|
||||
): Promise<TestTemplate> {
|
||||
const { data } = await client.patch<TestTemplate>(`/test-templates/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Stats (admin) ──────────────────────────────────────────────────
|
||||
|
||||
export interface TemplateStats {
|
||||
|
||||
@@ -61,8 +61,11 @@ export default function AuditLogPage() {
|
||||
|
||||
const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString("en-US", {
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ToggleRight,
|
||||
BarChart3,
|
||||
X,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
triggerMitreSync,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
getTemplateStats,
|
||||
getAllTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
toggleTemplateActive,
|
||||
bulkActivateTemplates,
|
||||
type TemplateStats,
|
||||
@@ -43,6 +45,7 @@ export default function SystemPage() {
|
||||
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
|
||||
|
||||
// ── Existing queries ─────────────────────────────────────────────
|
||||
const {
|
||||
@@ -119,6 +122,16 @@ export default function SystemPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<CreateTemplatePayload> }) =>
|
||||
updateTemplate(id, payload),
|
||||
onSuccess: (updated) => {
|
||||
setSelectedTemplate(updated);
|
||||
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const formatNextRun = (dateStr: string | null) => {
|
||||
if (!dateStr) return "Not scheduled";
|
||||
const date = new Date(dateStr);
|
||||
@@ -168,18 +181,11 @@ export default function SystemPage() {
|
||||
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm font-medium text-green-400">Sync Complete</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">New techniques:</span>
|
||||
<span className="ml-2 font-medium text-white">{syncResult.new}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Updated:</span>
|
||||
<span className="ml-2 font-medium text-white">{syncResult.updated}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-green-400">
|
||||
{syncResult.status === "started" ? "Sync Started" : "Sync Complete"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-400">{syncResult.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -474,9 +480,14 @@ export default function SystemPage() {
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="font-medium text-gray-200 truncate block max-w-[200px]">
|
||||
{tpl.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedTemplate(tpl)}
|
||||
className="flex items-center gap-1.5 text-left font-medium text-cyan-400 hover:text-cyan-300 truncate max-w-[200px] transition-colors"
|
||||
title="Click to view/edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span className="truncate">{tpl.name}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-mono text-xs text-cyan-400">
|
||||
@@ -691,6 +702,18 @@ export default function SystemPage() {
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Template Detail Modal */}
|
||||
{selectedTemplate && (
|
||||
<TemplateDetailModal
|
||||
template={selectedTemplate}
|
||||
onClose={() => setSelectedTemplate(null)}
|
||||
onSave={(id, payload) => updateTemplateMutation.mutate({ id, payload })}
|
||||
onToggleActive={(id) => toggleActiveMutation.mutate(id)}
|
||||
isSaving={updateTemplateMutation.isPending}
|
||||
isToggling={toggleActiveMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -901,3 +924,211 @@ function CreateTemplateForm({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Template Detail / Edit Modal ────────────────────────────────── */
|
||||
|
||||
function TemplateDetailModal({
|
||||
template,
|
||||
onClose,
|
||||
onSave,
|
||||
onToggleActive,
|
||||
isSaving,
|
||||
isToggling,
|
||||
}: {
|
||||
template: TestTemplate;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, payload: Partial<CreateTemplatePayload>) => void;
|
||||
onToggleActive: (id: string) => void;
|
||||
isSaving: boolean;
|
||||
isToggling: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<Partial<CreateTemplatePayload>>({
|
||||
name: template.name,
|
||||
description: template.description ?? "",
|
||||
attack_procedure: template.attack_procedure ?? "",
|
||||
expected_detection: template.expected_detection ?? "",
|
||||
platform: template.platform ?? "",
|
||||
tool_suggested: template.tool_suggested ?? "",
|
||||
severity: template.severity ?? "",
|
||||
mitre_technique_id: template.mitre_technique_id,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(template.id, form);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Edit Template</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-400 font-mono">{template.mitre_technique_id}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta badges */}
|
||||
<div className="flex flex-wrap gap-2 px-6 py-3 border-b border-gray-800">
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
template.source === "atomic_red_team"
|
||||
? "bg-red-900/50 text-red-400 border-red-500/30"
|
||||
: template.source === "mitre"
|
||||
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
|
||||
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
{template.source.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
template.is_active
|
||||
? "bg-green-900/50 text-green-400 border-green-500/30"
|
||||
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
{template.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Template Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name ?? ""}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">MITRE Technique ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mitre_technique_id ?? ""}
|
||||
onChange={(e) => setForm({ ...form, mitre_technique_id: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Platform</label>
|
||||
<select
|
||||
value={form.platform ?? ""}
|
||||
onChange={(e) => setForm({ ...form, platform: e.target.value || undefined })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="macos">macOS</option>
|
||||
<option value="cloud">Cloud</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Severity</label>
|
||||
<select
|
||||
value={form.severity ?? ""}
|
||||
onChange={(e) => setForm({ ...form, severity: e.target.value || undefined })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Description</label>
|
||||
<textarea
|
||||
value={form.description ?? ""}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Attack Procedure</label>
|
||||
<textarea
|
||||
value={form.attack_procedure ?? ""}
|
||||
onChange={(e) => setForm({ ...form, attack_procedure: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Expected Detection</label>
|
||||
<textarea
|
||||
value={form.expected_detection ?? ""}
|
||||
onChange={(e) => setForm({ ...form, expected_detection: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Suggested Tool</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.tool_suggested ?? ""}
|
||||
onChange={(e) => setForm({ ...form, tool_suggested: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-gray-800">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleActive(template.id)}
|
||||
disabled={isToggling}
|
||||
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
template.is_active
|
||||
? "border-red-500/30 bg-red-900/20 text-red-400 hover:bg-red-900/40"
|
||||
: "border-green-500/30 bg-green-900/20 text-green-400 hover:bg-green-900/40"
|
||||
}`}
|
||||
>
|
||||
{isToggling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : template.is_active ? (
|
||||
<ToggleRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
)}
|
||||
{template.is_active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user