feat: add migrate-owner endpoint for todos reassignment
All checks were successful
CI/CD / test (push) Successful in 45s
CI/CD / deploy (push) Successful in 2s

This commit is contained in:
2026-01-30 13:52:14 +00:00
parent 8407dde30b
commit 73bf9a69b1
5 changed files with 439 additions and 137 deletions

View File

@@ -1,15 +1,16 @@
FROM oven/bun:1 AS base
WORKDIR /app
# Install postgresql-client for SQL fallback
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
# Install dependencies
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
# Copy source
# Copy source and init script
COPY . .
# Generate migrations and run
# Cache buster: 2026-01-30-v3
EXPOSE 3100
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
COPY init-tables.sql /app/init-tables.sql
CMD ["sh", "-c", "echo 'Running init SQL...' && psql \"$DATABASE_URL\" -f /app/init-tables.sql 2>&1 && echo 'Init SQL done' && echo 'Running db:push...' && yes | bun run db:push 2>&1; echo 'db:push exit code:' $? && echo 'Starting server...' && bun run start"]
CMD ["sh", "-c", "echo 'Waiting for DB...' && sleep 5 && echo 'Running init SQL...' && psql \"$DATABASE_URL\" -f /app/init-tables.sql 2>&1 && echo 'Init SQL done' && echo 'Running db:push...' && yes | bun run db:push 2>&1; echo 'db:push exit code:' $? && echo 'Starting server...' && bun run start"]

View File

@@ -131,6 +131,7 @@ export interface SecurityFinding {
title: string;
description: string;
recommendation: string;
taskId?: string;
}
export const securityAudits = pgTable("security_audits", {

View File

@@ -29,6 +29,7 @@ const findingSchema = t.Object({
title: t.String(),
description: t.String(),
recommendation: t.String(),
taskId: t.Optional(t.String()),
});
export const securityRoutes = new Elysia({ prefix: "/api/security" })

View File

@@ -277,4 +277,22 @@ export const todoRoutes = new Elysia({ prefix: "/api/todos" })
createdAt: t.Optional(t.String()),
})),
}),
})
// POST reassign all bearer todos to a real user (one-time migration)
.post("/migrate-owner", async ({ body, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const result = await db
.update(todos)
.set({ userId: body.targetUserId, updatedAt: new Date() })
.where(eq(todos.userId, body.fromUserId))
.returning({ id: todos.id });
return { migrated: result.length };
}, {
body: t.Object({
fromUserId: t.String(),
targetUserId: t.String(),
}),
});

View File

@@ -8,6 +8,7 @@ interface SecurityFinding {
title: string;
description: string;
recommendation: string;
taskId?: string;
}
interface SecurityAudit {
@@ -89,6 +90,23 @@ async function _deleteAudit(id: string): Promise<void> {
}
export { _deleteAudit as deleteAudit };
async function createTask(data: {
title: string;
description: string;
priority: string;
source: string;
tags: string[];
}): Promise<{ id: string }> {
const res = await fetch("/api/tasks", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to create task");
return res.json();
}
// ─── Helpers ───
function scoreColor(score: number): string {
@@ -170,6 +188,60 @@ function timeAgo(dateStr: string): string {
return date.toLocaleDateString();
}
function letterGrade(score: number): string {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "F";
}
const PROJECT_NAMES = [
"Hammer Dashboard",
"Network App",
"Todo App",
"nKode",
"Infrastructure",
];
// ─── Toast Component ───
function Toast({
message,
type,
onClose,
}: {
message: string;
type: "success" | "error";
onClose: () => void;
}) {
useEffect(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-bottom-4">
<div
className={`flex items-center gap-3 px-5 py-3 rounded-xl shadow-lg border ${
type === "success"
? "bg-green-50 dark:bg-green-900/40 border-green-200 dark:border-green-700 text-green-800 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-700 text-red-800 dark:text-red-300"
}`}
>
<span className="text-lg">{type === "success" ? "✅" : "❌"}</span>
<span className="text-sm font-medium">{message}</span>
<button
onClick={onClose}
className="ml-2 opacity-60 hover:opacity-100 transition"
>
×
</button>
</div>
</div>
);
}
// ─── Score Ring Component ───
function ScoreRing({
@@ -216,6 +288,73 @@ function ScoreRing({
);
}
// ─── Create Fix Task Button ───
function CreateFixTaskButton({
finding,
projectName,
onTaskCreated,
}: {
finding: SecurityFinding;
projectName: string;
onTaskCreated: (findingId: string, taskId: string) => void;
}) {
const [creating, setCreating] = useState(false);
if (finding.status === "strong") return null;
if (finding.taskId) {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded-full">
Task Created
</span>
);
}
const handleCreate = async (e: React.MouseEvent) => {
e.stopPropagation();
setCreating(true);
try {
const priority = finding.status === "critical" ? "critical" : "high";
const task = await createTask({
title: `Security Fix: ${projectName}${finding.title}`,
description: `**Finding:** ${finding.description}\n\n**Recommendation:** ${finding.recommendation}\n\n**Category:** Security Audit\n**Severity:** ${finding.status === "critical" ? "Critical" : "Needs Improvement"}`,
priority,
source: "hammer",
tags: ["security"],
});
onTaskCreated(finding.id, task.id);
} catch (err) {
console.error("Failed to create task:", err);
} finally {
setCreating(false);
}
};
return (
<button
onClick={handleCreate}
disabled={creating}
className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-full transition ${
finding.status === "critical"
? "text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30"
: "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/30"
} disabled:opacity-50`}
>
{creating ? (
<>
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
Creating...
</>
) : (
<>🔨 Create Fix Task</>
)}
</button>
);
}
// ─── Finding Editor Modal ───
function FindingEditorModal({
@@ -305,7 +444,6 @@ function FindingEditorModal({
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Score */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Score (0100)
@@ -320,7 +458,6 @@ function FindingEditorModal({
/>
</div>
{/* Findings */}
<div className="space-y-3">
{findings.map((finding, i) => (
<div
@@ -445,14 +582,6 @@ function AddAuditModal({
"Compliance",
];
const projects = [
"Hammer Dashboard",
"Network App",
"Todo App",
"nKode",
"Infrastructure",
];
const handleCreate = async () => {
if (!projectName) return;
setSaving(true);
@@ -490,7 +619,7 @@ function AddAuditModal({
className="w-full border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-300"
>
<option value="">Select project...</option>
{projects.map((p) => (
{PROJECT_NAMES.map((p) => (
<option key={p} value={p}>
{p}
</option>
@@ -547,15 +676,62 @@ function AddAuditModal({
);
}
// ─── Project Card ───
// ─── Project Tab Bar ───
function ProjectTabBar({
projects,
selected,
onSelect,
}: {
projects: string[];
selected: string | null;
onSelect: (project: string | null) => void;
}) {
return (
<div className="flex items-center gap-1 overflow-x-auto pb-1 mb-6 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => onSelect(null)}
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition whitespace-nowrap ${
selected === null
? "text-amber-600 dark:text-amber-400 border-b-2 border-amber-500 bg-amber-50/50 dark:bg-amber-900/10"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
📊 Overview
</button>
{projects.map((project) => (
<button
key={project}
onClick={() => onSelect(project)}
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition whitespace-nowrap ${
selected === project
? "text-amber-600 dark:text-amber-400 border-b-2 border-amber-500 bg-amber-50/50 dark:bg-amber-900/10"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
{project}
</button>
))}
</div>
);
}
// ─── Project Score Card ───
function ProjectScoreCard({
summary,
audits,
onClick,
}: {
summary: ProjectSummary;
audits: SecurityAudit[];
onClick: () => void;
}) {
const projectAudits = audits.filter((a) => a.projectName === summary.projectName);
const allFindings = projectAudits.flatMap((a) => a.findings || []);
const criticalCount = allFindings.filter((f) => f.status === "critical").length;
const warningCount = allFindings.filter((f) => f.status === "needs_improvement").length;
return (
<button
onClick={onClick}
@@ -564,13 +740,35 @@ function ProjectScoreCard({
<div className="flex items-center gap-4">
<ScoreRing score={summary.averageScore} size={64} />
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition truncate">
{summary.projectName}
</h3>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition truncate">
{summary.projectName}
</h3>
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${scoreColor(summary.averageScore)} ${
summary.averageScore >= 80
? "bg-green-100 dark:bg-green-900/30"
: summary.averageScore >= 50
? "bg-yellow-100 dark:bg-yellow-900/30"
: "bg-red-100 dark:bg-red-900/30"
}`}>
{letterGrade(summary.averageScore)}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{summary.categoriesAudited} categories audited
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
<div className="flex gap-3 mt-1.5 text-xs text-gray-400 dark:text-gray-500">
{criticalCount > 0 && (
<span className="text-red-500"> {criticalCount} critical</span>
)}
{warningCount > 0 && (
<span className="text-yellow-500"> {warningCount} warnings</span>
)}
{criticalCount === 0 && warningCount === 0 && (
<span className="text-green-500"> All clear</span>
)}
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
Last audited: {timeAgo(summary.lastAudited)}
</p>
</div>
@@ -584,9 +782,11 @@ function ProjectScoreCard({
function CategoryCard({
audit,
onEdit,
onTaskCreated,
}: {
audit: SecurityAudit;
onEdit: () => void;
onTaskCreated: (auditId: string, findingId: string, taskId: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const findings = audit.findings || [];
@@ -654,7 +854,7 @@ function CategoryCard({
{statusIcon(finding.status)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{finding.title}
</span>
@@ -663,6 +863,13 @@ function CategoryCard({
>
{statusLabel(finding.status)}
</span>
<CreateFixTaskButton
finding={finding}
projectName={audit.projectName}
onTaskCreated={(findingId, taskId) =>
onTaskCreated(audit.id, findingId, taskId)
}
/>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{finding.description}
@@ -707,15 +914,14 @@ function CategoryCard({
function ProjectDetail({
projectName,
audits,
onBack,
onRefresh,
onTaskCreated,
}: {
projectName: string;
audits: SecurityAudit[];
onBack: () => void;
onRefresh: () => void;
onTaskCreated: (auditId: string, findingId: string, taskId: string) => void;
}) {
const [editingAudit, setEditingAudit] = useState<SecurityAudit | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const projectAudits = audits.filter((a) => a.projectName === projectName);
const avgScore = projectAudits.length
@@ -740,72 +946,75 @@ function ProjectDetail({
(a.findings?.filter((f) => f.status === "needs_improvement").length || 0),
0
);
const strongFindings = projectAudits.reduce(
(sum, a) =>
sum + (a.findings?.filter((f) => f.status === "strong").length || 0),
0
);
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={onBack}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{projectName}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Security Audit Details
</p>
<div className="max-w-4xl mx-auto" key={refreshKey}>
{/* Header with score summary */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-6">
<ScoreRing score={avgScore} size={96} />
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{projectName}
</h2>
<span className={`text-sm font-bold px-2 py-0.5 rounded ${scoreColor(avgScore)} ${
avgScore >= 80
? "bg-green-100 dark:bg-green-900/30"
: avgScore >= 50
? "bg-yellow-100 dark:bg-yellow-900/30"
: "bg-red-100 dark:bg-red-900/30"
}`}>
Grade: {letterGrade(avgScore)}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{projectAudits.length} categories {totalFindings} total findings
</p>
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-gray-600 dark:text-gray-400">
{strongFindings} strong
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-gray-600 dark:text-gray-400">
{warningFindings} warnings
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-gray-600 dark:text-gray-400">
{criticalFindings} critical
</span>
</div>
</div>
</div>
</div>
<ScoreRing score={avgScore} size={56} />
</div>
{/* Stats bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
<div className={`text-xl font-bold ${scoreColor(avgScore)}`}>
{avgScore}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">
Avg Score
</div>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{totalFindings}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">
Findings
</div>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
<div className="text-xl font-bold text-red-500">
{criticalFindings}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">
Critical
</div>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
<div className="text-xl font-bold text-yellow-500">
{warningFindings}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">
Warnings
</div>
{/* Score bar per category */}
<div className="mt-5 grid grid-cols-2 sm:grid-cols-4 gap-2">
{projectAudits.map((audit) => (
<div
key={audit.id}
className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-2.5 text-center"
>
<div className="text-lg mb-0.5">{categoryIcon(audit.category)}</div>
<div className={`text-sm font-bold ${scoreColor(audit.score)}`}>
{audit.score}
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">
{audit.category}
</div>
</div>
))}
</div>
</div>
@@ -816,6 +1025,7 @@ function ProjectDetail({
key={audit.id}
audit={audit}
onEdit={() => setEditingAudit(audit)}
onTaskCreated={onTaskCreated}
/>
))}
</div>
@@ -833,7 +1043,7 @@ function ProjectDetail({
onClose={() => setEditingAudit(null)}
onSaved={() => {
setEditingAudit(null);
onRefresh();
setRefreshKey((k) => k + 1);
}}
/>
)}
@@ -870,9 +1080,20 @@ function PostureSummary({
<div className="flex items-center gap-6">
<ScoreRing score={overallScore} size={96} />
<div className="flex-1">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
Overall Security Posture
</h2>
<div className="flex items-center gap-3 mb-1">
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
Overall Security Posture
</h2>
<span className={`text-sm font-bold px-2 py-0.5 rounded ${scoreColor(overallScore)} ${
overallScore >= 80
? "bg-green-100 dark:bg-green-900/30"
: overallScore >= 50
? "bg-yellow-100 dark:bg-yellow-900/30"
: "bg-red-100 dark:bg-red-900/30"
}`}>
{letterGrade(overallScore)}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{summary.length} projects {audits.length} category audits {" "}
{allFindings.length} findings
@@ -911,6 +1132,10 @@ export function SecurityPage() {
const [loading, setLoading] = useState(true);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [toast, setToast] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
const loadAll = useCallback(async () => {
try {
@@ -931,6 +1156,50 @@ export function SecurityPage() {
loadAll();
}, [loadAll]);
// Handle task creation: update finding with taskId locally and persist
const handleTaskCreated = useCallback(
async (auditId: string, findingId: string, taskId: string) => {
// Update local state
setAudits((prev) =>
prev.map((audit) => {
if (audit.id !== auditId) return audit;
return {
...audit,
findings: audit.findings.map((f) =>
f.id === findingId ? { ...f, taskId } : f
),
};
})
);
// Persist to backend
const audit = audits.find((a) => a.id === auditId);
if (audit) {
const updatedFindings = audit.findings.map((f) =>
f.id === findingId ? { ...f, taskId } : f
);
try {
await updateAudit(auditId, { findings: updatedFindings });
} catch (e) {
console.error("Failed to persist taskId:", e);
}
}
setToast({ message: "Fix task created in Hammer Queue!", type: "success" });
},
[audits]
);
// Get unique project names from data
const projectNames = [...new Set(audits.map((a) => a.projectName))].sort(
(a, b) => {
const order = PROJECT_NAMES;
const ai = order.indexOf(a);
const bi = order.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
}
);
if (loading) {
return (
<div className="p-4 sm:p-6">
@@ -941,26 +1210,10 @@ export function SecurityPage() {
);
}
if (selectedProject) {
return (
<div className="p-4 sm:p-6">
<ProjectDetail
projectName={selectedProject}
audits={audits}
onBack={() => {
setSelectedProject(null);
loadAll();
}}
onRefresh={loadAll}
/>
</div>
);
}
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white">
🛡 Security Audit
@@ -977,36 +1230,56 @@ export function SecurityPage() {
</button>
</div>
{/* Overall posture */}
<PostureSummary audits={audits} summary={summary} />
{/* Project Tabs */}
<ProjectTabBar
projects={projectNames}
selected={selectedProject}
onSelect={setSelectedProject}
/>
{/* Project score cards */}
{summary.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summary.map((s) => (
<ProjectScoreCard
key={s.projectName}
summary={s}
onClick={() => setSelectedProject(s.projectName)}
/>
))}
</div>
{/* Content */}
{selectedProject === null ? (
<>
{/* Overview */}
<PostureSummary audits={audits} summary={summary} />
{/* Project score cards */}
{summary.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summary.map((s) => (
<ProjectScoreCard
key={s.projectName}
summary={s}
audits={audits}
onClick={() => setSelectedProject(s.projectName)}
/>
))}
</div>
) : (
<div className="text-center py-16">
<span className="text-5xl block mb-4">🛡</span>
<h2 className="text-lg font-semibold text-gray-600 dark:text-gray-400 mb-2">
No audit data yet
</h2>
<p className="text-sm text-gray-400 mb-4">
Add security audit entries to start tracking your security
posture
</p>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
Add First Audit
</button>
</div>
)}
</>
) : (
<div className="text-center py-16">
<span className="text-5xl block mb-4">🛡</span>
<h2 className="text-lg font-semibold text-gray-600 dark:text-gray-400 mb-2">
No audit data yet
</h2>
<p className="text-sm text-gray-400 mb-4">
Add security audit entries to start tracking your security posture
</p>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
Add First Audit
</button>
</div>
<ProjectDetail
projectName={selectedProject}
audits={audits}
onTaskCreated={handleTaskCreated}
/>
)}
{showAddModal && (
@@ -1018,6 +1291,14 @@ export function SecurityPage() {
}}
/>
)}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
}