@cybermem/dashboard 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/app/api/audit-logs/route.ts +4 -1
- package/app/api/health/route.ts +94 -0
- package/app/api/settings/regenerate/route.ts +32 -3
- package/app/api/settings/route.ts +30 -4
- package/app/client-connect/page.tsx +45 -0
- package/app/page.tsx +28 -1
- package/components/dashboard/audit-log-table.tsx +102 -9
- package/components/dashboard/chart-card.tsx +5 -4
- package/components/dashboard/header.tsx +79 -5
- package/components/dashboard/login-modal.tsx +0 -4
- package/components/dashboard/mcp-config-modal.tsx +56 -12
- package/components/dashboard/metrics-grid.tsx +17 -4
- package/components/dashboard/password-alert-modal.tsx +72 -0
- package/components/dashboard/settings-modal.tsx +82 -217
- package/e2e/audit-export.spec.ts +111 -0
- package/e2e/auth.spec.ts +37 -0
- package/e2e/config-ui.spec.ts +98 -0
- package/e2e/crud-happy-path.spec.ts +214 -0
- package/e2e/metrics.spec.ts +28 -0
- package/eslint.config.mjs +15 -0
- package/lib/data/dashboard-context.tsx +44 -1
- package/lib/rate-limit.ts +77 -0
- package/middleware.ts +43 -0
- package/package.json +16 -5
- package/playwright.config.ts +35 -0
- package/public/clients.json +61 -35
- package/.eslintrc.json +0 -3
- package/shared.env +0 -0
|
@@ -4,19 +4,19 @@ import { Button } from "@/components/ui/button"
|
|
|
4
4
|
import { Input } from "@/components/ui/input"
|
|
5
5
|
import { Label } from "@/components/ui/label"
|
|
6
6
|
import { useDashboard } from "@/lib/data/dashboard-context"
|
|
7
|
-
import { Check, Copy, Eye, EyeOff, Key,
|
|
7
|
+
import { Check, Copy, Eye, EyeOff, Key, Server, Settings, Shield, X } from "lucide-react"
|
|
8
8
|
import { useEffect, useState } from "react"
|
|
9
9
|
|
|
10
10
|
export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
11
11
|
const [apiKey, setApiKey] = useState("")
|
|
12
12
|
const [endpoint, setEndpoint] = useState("")
|
|
13
|
+
const [isManaged, setIsManaged] = useState(false)
|
|
13
14
|
const [adminPassword, setAdminPassword] = useState(localStorage.getItem("adminPassword") || "admin")
|
|
14
15
|
const [isLoading, setIsLoading] = useState(true)
|
|
15
16
|
|
|
16
17
|
const { isDemo, toggleDemo } = useDashboard()
|
|
17
18
|
const [showApiKey, setShowApiKey] = useState(false)
|
|
18
19
|
const [showAdminPassword, setShowAdminPassword] = useState(false)
|
|
19
|
-
/* Demo mode controlled by context now */
|
|
20
20
|
|
|
21
21
|
const [showRegenConfirm, setShowRegenConfirm] = useState(false)
|
|
22
22
|
const [regenInputValue, setRegenInputValue] = useState("")
|
|
@@ -30,46 +30,35 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
30
30
|
|
|
31
31
|
// Fetch settings from server
|
|
32
32
|
useEffect(() => {
|
|
33
|
-
|
|
33
|
+
setIsLoading(true)
|
|
34
34
|
const localKey = localStorage.getItem("om_api_key")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
setIsLoading(false)
|
|
46
|
-
})
|
|
47
|
-
.catch(err => setIsLoading(false))
|
|
48
|
-
} else {
|
|
49
|
-
fetch("/api/settings")
|
|
50
|
-
.then(res => res.json())
|
|
51
|
-
.then(data => {
|
|
35
|
+
|
|
36
|
+
fetch("/api/settings")
|
|
37
|
+
.then(res => res.json())
|
|
38
|
+
.then(data => {
|
|
39
|
+
// Enforce Local Mode if server says so
|
|
40
|
+
setIsManaged(data.isManaged || false)
|
|
41
|
+
|
|
42
|
+
if (localKey && !data.isManaged) {
|
|
43
|
+
setApiKey(localKey)
|
|
44
|
+
} else {
|
|
52
45
|
setApiKey(data.apiKey !== 'not-set' ? data.apiKey : '')
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let srvEndpoint = data.endpoint
|
|
49
|
+
if (srvEndpoint.includes('localhost') && typeof window !== "undefined" && !window.location.hostname.includes('localhost')) {
|
|
50
|
+
const port = srvEndpoint.split(':').pop()?.split('/')[0] || '8626'
|
|
51
|
+
srvEndpoint = `${window.location.protocol}//${window.location.hostname}:${port}/mcp`
|
|
52
|
+
}
|
|
53
|
+
setEndpoint(srvEndpoint)
|
|
54
|
+
setIsLoading(false)
|
|
55
|
+
})
|
|
56
|
+
.catch(err => {
|
|
57
|
+
console.error("Failed to fetch settings:", err)
|
|
58
|
+
setIsLoading(false)
|
|
59
|
+
})
|
|
65
60
|
}, [])
|
|
66
61
|
|
|
67
|
-
const generateApiKey = () => {
|
|
68
|
-
// Legacy direct generation - now just triggers the confirmation flow if button linked here
|
|
69
|
-
// But the button in UI calls setShowRegenConfirm(true) directly.
|
|
70
|
-
// We can keep this empty or redirect.
|
|
71
|
-
}
|
|
72
|
-
|
|
73
62
|
const confirmRegenerate = async () => {
|
|
74
63
|
try {
|
|
75
64
|
const res = await fetch('/api/settings/regenerate', { method: 'POST' })
|
|
@@ -91,7 +80,6 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
91
80
|
const [saved, setSaved] = useState(false)
|
|
92
81
|
|
|
93
82
|
const handleSave = () => {
|
|
94
|
-
// Save admin password to localStorage
|
|
95
83
|
localStorage.setItem("adminPassword", adminPassword)
|
|
96
84
|
setSaved(true)
|
|
97
85
|
setTimeout(() => setSaved(false), 2000)
|
|
@@ -99,31 +87,18 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
99
87
|
|
|
100
88
|
return (
|
|
101
89
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
|
102
|
-
<div
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
backgroundImage: `
|
|
106
|
-
radial-gradient(circle at 0% 0%, oklch(0.7 0 0 / 0.05) 0%, transparent 50%),
|
|
107
|
-
radial-gradient(circle at 100% 0%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%),
|
|
108
|
-
radial-gradient(circle at 100% 100%, oklch(0.65 0 0 / 0.05) 0%, transparent 50%),
|
|
109
|
-
radial-gradient(circle at 0% 100%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%)
|
|
110
|
-
`
|
|
111
|
-
}}
|
|
112
|
-
>
|
|
90
|
+
<div className="bg-[#0B1116]/80 backdrop-blur-xl border border-emerald-500/20 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto relative overflow-hidden"
|
|
91
|
+
style={{ backgroundImage: `radial-gradient(circle at 0% 0%, oklch(0.7 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 100% 0%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 100% 100%, oklch(0.65 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 0% 100%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%)` }}>
|
|
92
|
+
|
|
113
93
|
{/* Header */}
|
|
114
94
|
<div className="flex items-center justify-between px-6 pt-6 pb-2">
|
|
115
95
|
<div className="flex items-center gap-3">
|
|
116
96
|
<div className="p-2 bg-white/5 rounded-lg border border-white/10 shadow-inner">
|
|
117
|
-
<Settings className="w-5 h-5 text-white
|
|
97
|
+
<Settings className="w-5 h-5 text-white shadow-lg" />
|
|
118
98
|
</div>
|
|
119
|
-
<h2 className="text-xl font-semibold text-white
|
|
99
|
+
<h2 className="text-xl font-semibold text-white">Settings</h2>
|
|
120
100
|
</div>
|
|
121
|
-
<Button
|
|
122
|
-
variant="ghost"
|
|
123
|
-
size="icon"
|
|
124
|
-
onClick={onClose}
|
|
125
|
-
className="text-neutral-400 hover:text-white hover:bg-white/10 rounded-full"
|
|
126
|
-
>
|
|
101
|
+
<Button variant="ghost" size="icon" onClick={onClose} className="text-neutral-400 hover:text-white rounded-full">
|
|
127
102
|
<X className="w-5 h-5" />
|
|
128
103
|
</Button>
|
|
129
104
|
</div>
|
|
@@ -132,188 +107,91 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
132
107
|
<div className="p-6 space-y-6">
|
|
133
108
|
{/* Security */}
|
|
134
109
|
<section>
|
|
135
|
-
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2
|
|
136
|
-
<Shield className="w-5 h-5
|
|
110
|
+
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
111
|
+
<Shield className="w-5 h-5" />
|
|
137
112
|
Security
|
|
138
113
|
</h3>
|
|
139
114
|
<div className="bg-white/5 border border-white/10 rounded-lg p-5 space-y-4 shadow-[inset_0_0_20px_rgba(255,255,255,0.02)] backdrop-blur-sm">
|
|
140
115
|
<div className="space-y-2">
|
|
141
|
-
<Label htmlFor="admin-password"
|
|
142
|
-
<div className="
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
onChange={(e) => setAdminPassword(e.target.value)}
|
|
148
|
-
className="bg-black/40 border-white/10 text-white focus-visible:border-emerald-500/30 focus-visible:ring-emerald-500/10 placeholder:text-neutral-600 shadow-inner pr-10"
|
|
149
|
-
type={showAdminPassword ? "text" : "password"}
|
|
150
|
-
/>
|
|
151
|
-
<button
|
|
152
|
-
type="button"
|
|
153
|
-
onClick={() => setShowAdminPassword(!showAdminPassword)}
|
|
154
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white transition-colors"
|
|
155
|
-
>
|
|
156
|
-
{showAdminPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
157
|
-
</button>
|
|
158
|
-
</div>
|
|
116
|
+
<Label htmlFor="admin-password">Admin Password</Label>
|
|
117
|
+
<div className="relative">
|
|
118
|
+
<Input id="admin-password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} className="bg-black/40 border-white/10 text-white" type={showAdminPassword ? "text" : "password"} />
|
|
119
|
+
<button onClick={() => setShowAdminPassword(!showAdminPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white">
|
|
120
|
+
{showAdminPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
121
|
+
</button>
|
|
159
122
|
</div>
|
|
160
|
-
<p className="text-xs text-neutral-500">Used to access the dashboard</p>
|
|
161
123
|
</div>
|
|
162
124
|
</div>
|
|
163
125
|
</section>
|
|
164
126
|
|
|
165
127
|
{/* API Configuration */}
|
|
128
|
+
{!isManaged ? (
|
|
166
129
|
<section>
|
|
167
|
-
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2
|
|
168
|
-
<Key className="w-5 h-5
|
|
130
|
+
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
131
|
+
<Key className="w-5 h-5" />
|
|
169
132
|
API Configuration
|
|
170
133
|
</h3>
|
|
171
134
|
<div className="bg-white/5 border border-white/10 rounded-lg p-5 space-y-4 shadow-[inset_0_0_20px_rgba(255,255,255,0.02)] backdrop-blur-sm">
|
|
172
135
|
<div className="space-y-2">
|
|
173
|
-
<Label htmlFor="api-key"
|
|
136
|
+
<Label htmlFor="api-key">Master API Key</Label>
|
|
174
137
|
<div className="flex gap-2">
|
|
175
138
|
<div className="relative flex-1">
|
|
176
|
-
<Input
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
readOnly
|
|
180
|
-
className="bg-black/40 border-white/10 text-white focus-visible:border-emerald-500/30 focus-visible:ring-emerald-500/10 placeholder:text-neutral-600 shadow-inner pr-10 font-mono"
|
|
181
|
-
type={showApiKey ? "text" : "password"}
|
|
182
|
-
/>
|
|
183
|
-
<button
|
|
184
|
-
type="button"
|
|
185
|
-
onClick={() => setShowApiKey(!showApiKey)}
|
|
186
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white transition-colors"
|
|
187
|
-
>
|
|
188
|
-
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
139
|
+
<Input id="api-key" value={apiKey || "sk-not-generated-yet"} readOnly className="bg-black/40 border-white/10 text-white font-mono" type={showApiKey ? "text" : "password"} />
|
|
140
|
+
<button onClick={() => setShowApiKey(!showApiKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white">
|
|
141
|
+
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
189
142
|
</button>
|
|
190
143
|
</div>
|
|
191
|
-
<Button
|
|
192
|
-
|
|
193
|
-
variant="ghost"
|
|
194
|
-
className="h-10 w-10 border border-white/10 bg-white/5 hover:bg-white/10 text-neutral-400 hover:text-white"
|
|
195
|
-
onClick={() => copyToClipboard(apiKey, "apikey")}
|
|
196
|
-
title="Copy API Key"
|
|
197
|
-
>
|
|
198
|
-
{copiedId === "apikey" ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
|
144
|
+
<Button size="icon" variant="ghost" onClick={() => copyToClipboard(apiKey, "apikey")}>
|
|
145
|
+
{copiedId === "apikey" ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
|
199
146
|
</Button>
|
|
200
147
|
</div>
|
|
201
|
-
|
|
202
|
-
{/* Regeneration Controls */}
|
|
203
148
|
<div className="flex justify-end pt-2">
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<Button
|
|
214
|
-
size="sm"
|
|
215
|
-
variant="ghost"
|
|
216
|
-
className="h-8 px-3 text-neutral-400 hover:text-white hover:bg-white/10"
|
|
217
|
-
onClick={() => {
|
|
218
|
-
setShowRegenConfirm(false)
|
|
219
|
-
setRegenInputValue("")
|
|
220
|
-
}}
|
|
221
|
-
>
|
|
222
|
-
Cancel
|
|
223
|
-
</Button>
|
|
224
|
-
<Button
|
|
225
|
-
size="sm"
|
|
226
|
-
disabled={regenInputValue !== 'agree'}
|
|
227
|
-
className="h-8 px-3 bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
228
|
-
onClick={confirmRegenerate}
|
|
229
|
-
>
|
|
230
|
-
Confirm
|
|
231
|
-
</Button>
|
|
232
|
-
</div>
|
|
233
|
-
) : (
|
|
234
|
-
<Button
|
|
235
|
-
size="sm"
|
|
236
|
-
variant="ghost"
|
|
237
|
-
className="h-8 px-2 text-neutral-400 hover:text-white hover:bg-white/10"
|
|
238
|
-
onClick={() => setShowRegenConfirm(true)}
|
|
239
|
-
>
|
|
240
|
-
Regenerate Key
|
|
241
|
-
</Button>
|
|
242
|
-
)}
|
|
149
|
+
{showRegenConfirm ? (
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<Input value={regenInputValue} onChange={(e) => setRegenInputValue(e.target.value)} placeholder="Type 'agree'" className="h-8 w-24 text-xs" />
|
|
152
|
+
<Button size="sm" variant="ghost" onClick={() => setShowRegenConfirm(false)}>Cancel</Button>
|
|
153
|
+
<Button size="sm" disabled={regenInputValue !== 'agree'} onClick={confirmRegenerate}>Confirm</Button>
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<Button size="sm" variant="ghost" onClick={() => setShowRegenConfirm(true)}>Regenerate Key</Button>
|
|
157
|
+
)}
|
|
243
158
|
</div>
|
|
244
|
-
<p className="text-xs text-neutral-500">Your master secret key for all MCP clients</p>
|
|
245
159
|
</div>
|
|
246
160
|
|
|
247
161
|
<div className="space-y-2">
|
|
248
|
-
<Label htmlFor="endpoint"
|
|
249
|
-
<
|
|
250
|
-
<Input
|
|
251
|
-
id="endpoint"
|
|
252
|
-
value={endpoint}
|
|
253
|
-
onChange={(e) => setEndpoint(e.target.value)}
|
|
254
|
-
className="bg-black/40 border-white/10 text-white focus-visible:border-emerald-500/30 focus-visible:ring-emerald-500/10 placeholder:text-neutral-600 shadow-inner"
|
|
255
|
-
/>
|
|
256
|
-
</div>
|
|
257
|
-
<p className="text-xs text-neutral-500">URL of the OpenMemory backend instance</p>
|
|
162
|
+
<Label htmlFor="endpoint">Server Endpoint</Label>
|
|
163
|
+
<Input id="endpoint" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} className="bg-black/40 border-white/10 text-white" />
|
|
258
164
|
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</section>
|
|
167
|
+
) : (
|
|
168
|
+
<section>
|
|
169
|
+
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
170
|
+
<Shield className="w-5 h-5 text-emerald-400" />
|
|
171
|
+
API Security
|
|
172
|
+
</h3>
|
|
173
|
+
<div className="bg-emerald-500/5 border border-emerald-500/20 rounded-lg p-5 space-y-2 backdrop-blur-sm">
|
|
174
|
+
<p className="text-sm font-medium text-emerald-300">Local Mode Active</p>
|
|
175
|
+
<p className="text-xs text-emerald-200/60">No API key required for connection from your laptop. Key management is hidden.</p>
|
|
259
176
|
|
|
260
|
-
<div className="pt-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20"
|
|
264
|
-
onClick={async () => {
|
|
265
|
-
if(!confirm("Are you sure you want to restart the OpenMemory server?")) return;
|
|
266
|
-
try {
|
|
267
|
-
const res = await fetch('/api/system/restart', { method: 'POST' })
|
|
268
|
-
if(res.ok) alert("Server restarting... Please wait a moment.")
|
|
269
|
-
else alert("Failed to restart server")
|
|
270
|
-
} catch(e) {
|
|
271
|
-
alert("Error triggering restart")
|
|
272
|
-
}
|
|
273
|
-
}}
|
|
274
|
-
>
|
|
275
|
-
Restart Server
|
|
276
|
-
</Button>
|
|
177
|
+
<div className="mt-4 pt-4 border-t border-white/10">
|
|
178
|
+
<Label htmlFor="endpoint" className="text-xs text-neutral-500 mb-2 block">System Endpoint</Label>
|
|
179
|
+
<Input id="endpoint" value={endpoint} readOnly className="h-9 bg-black/40 border-white/10 text-neutral-400 text-sm" />
|
|
277
180
|
</div>
|
|
278
181
|
</div>
|
|
279
182
|
</section>
|
|
183
|
+
)}
|
|
280
184
|
|
|
281
185
|
{/* System Info */}
|
|
282
186
|
<section>
|
|
283
|
-
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2
|
|
284
|
-
<Server className="w-5 h-5
|
|
187
|
+
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
188
|
+
<Server className="w-5 h-5" />
|
|
285
189
|
System
|
|
286
190
|
</h3>
|
|
287
191
|
<div className="bg-white/5 border border-white/10 rounded-lg p-5 shadow-[inset_0_0_20px_rgba(255,255,255,0.02)] backdrop-blur-sm">
|
|
288
192
|
<div className="grid grid-cols-2 gap-4">
|
|
289
|
-
<div>
|
|
290
|
-
|
|
291
|
-
<p className="text-neutral-200 font-mono mt-1">v0.1.0-beta</p>
|
|
292
|
-
</div>
|
|
293
|
-
<div>
|
|
294
|
-
<span className="text-xs uppercase tracking-wider text-neutral-500 font-semibold">Environment</span>
|
|
295
|
-
<p className="text-emerald-400 font-mono mt-1 text-shadow-emerald">Production</p>
|
|
296
|
-
</div>
|
|
297
|
-
</div>
|
|
298
|
-
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
|
299
|
-
<div>
|
|
300
|
-
<label htmlFor="demo-mode" className="text-neutral-200 font-medium block">Demo Mode</label>
|
|
301
|
-
<p className="text-xs text-neutral-500 mt-1">Generate simulated traffic for demonstration</p>
|
|
302
|
-
</div>
|
|
303
|
-
<div className="relative inline-block w-12 h-6 transition duration-200 ease-in-out rounded-full border border-white/10">
|
|
304
|
-
<input
|
|
305
|
-
type="checkbox"
|
|
306
|
-
id="demo-mode"
|
|
307
|
-
className="peer absolute w-0 h-0 opacity-0"
|
|
308
|
-
checked={isDemo}
|
|
309
|
-
onChange={toggleDemo}
|
|
310
|
-
/>
|
|
311
|
-
<label
|
|
312
|
-
htmlFor="demo-mode"
|
|
313
|
-
className={`block w-full h-full rounded-full cursor-pointer transition-colors duration-300 ${isDemo ? "bg-emerald-500/30" : "bg-black/40"}`}
|
|
314
|
-
></label>
|
|
315
|
-
<div className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-300 ${isDemo ? "translate-x-6 bg-emerald-100" : "translate-x-0 bg-neutral-400"}`}></div>
|
|
316
|
-
</div>
|
|
193
|
+
<div><span className="text-xs uppercase text-neutral-500 font-semibold">Version</span><p className="text-neutral-200 font-mono mt-1">v0.2.0</p></div>
|
|
194
|
+
<div><span className="text-xs uppercase text-neutral-500 font-semibold">Environment</span><p className="text-emerald-400 font-mono mt-1">Production</p></div>
|
|
317
195
|
</div>
|
|
318
196
|
</div>
|
|
319
197
|
</section>
|
|
@@ -321,21 +199,8 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
321
199
|
|
|
322
200
|
{/* Footer */}
|
|
323
201
|
<div className="sticky bottom-0 bg-[#0B1116]/80 backdrop-blur-md border-t border-emerald-500/20 px-6 py-4 flex justify-end gap-3 z-10">
|
|
324
|
-
<Button
|
|
325
|
-
|
|
326
|
-
className="bg-white/5 hover:bg-white/10 border border-white/10 text-neutral-300 transition-colors"
|
|
327
|
-
>
|
|
328
|
-
Cancel
|
|
329
|
-
</Button>
|
|
330
|
-
<Button
|
|
331
|
-
onClick={handleSave}
|
|
332
|
-
className={`border font-medium transition-colors gap-2 ${
|
|
333
|
-
saved
|
|
334
|
-
? "bg-emerald-500/30 border-emerald-500/30 text-emerald-300"
|
|
335
|
-
: "bg-emerald-500/20 hover:bg-emerald-500/30 border-emerald-500/20 text-emerald-400"
|
|
336
|
-
}`}
|
|
337
|
-
>
|
|
338
|
-
<Save className="w-4 h-4" />
|
|
202
|
+
<Button onClick={onClose} variant="ghost">Cancel</Button>
|
|
203
|
+
<Button onClick={handleSave} className="bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 border border-emerald-500/20">
|
|
339
204
|
{saved ? "Saved!" : "Save Changes"}
|
|
340
205
|
</Button>
|
|
341
206
|
</div>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Tests for Audit Log Export
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the audit log export functionality works correctly.
|
|
5
|
+
* Run with: npx playwright test or npm run test:e2e
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { expect, test } from '@playwright/test';
|
|
9
|
+
|
|
10
|
+
test.describe('Audit Log Export', () => {
|
|
11
|
+
test.beforeEach(async ({ page }) => {
|
|
12
|
+
// Navigate to dashboard
|
|
13
|
+
await page.goto('http://localhost:3000');
|
|
14
|
+
|
|
15
|
+
// Handle login if redirected or modal visible
|
|
16
|
+
const passwordInput = page.getByPlaceholder('Enter admin password');
|
|
17
|
+
if (await passwordInput.isVisible()) {
|
|
18
|
+
await passwordInput.fill('admin');
|
|
19
|
+
await page.keyboard.press('Enter');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Handle Password Alert Modal (if it appears on default password)
|
|
23
|
+
const dontShowAgainButton = page.locator('button:has-text("Don\'t show again")');
|
|
24
|
+
if (await dontShowAgainButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
25
|
+
await dontShowAgainButton.click();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Wait for dashboard to fully load
|
|
29
|
+
await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible({ timeout: 15000 });
|
|
30
|
+
|
|
31
|
+
// Force scroll to bottom or click tab to ensure Audit Log is active
|
|
32
|
+
const auditHeader = page.locator('h3:has-text("Audit Log")');
|
|
33
|
+
await auditHeader.scrollIntoViewIfNeeded();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('should display export button in audit log table', async ({ page }) => {
|
|
37
|
+
// Wait for audit log table headers to ensure content failed
|
|
38
|
+
await expect(page.locator('th:has-text("Timestamp")')).toBeVisible({ timeout: 10000 });
|
|
39
|
+
|
|
40
|
+
// Check export button exists
|
|
41
|
+
const exportButton = page.locator('button:has-text("Export")');
|
|
42
|
+
await expect(exportButton).toBeVisible();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should show export dropdown with CSV and JSON options', async ({ page }) => {
|
|
46
|
+
// Wait for table
|
|
47
|
+
await page.waitForSelector('text=Audit Log');
|
|
48
|
+
|
|
49
|
+
// Click export button
|
|
50
|
+
const exportButton = page.locator('button:has-text("Export")');
|
|
51
|
+
await exportButton.click();
|
|
52
|
+
|
|
53
|
+
// Verify dropdown options
|
|
54
|
+
await expect(page.locator('button:has-text("Export CSV")')).toBeVisible();
|
|
55
|
+
await expect(page.locator('button:has-text("Export JSON")')).toBeVisible();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('should download CSV file when clicking Export CSV', async ({ page }) => {
|
|
59
|
+
await page.waitForSelector('text=Audit Log');
|
|
60
|
+
|
|
61
|
+
// Click export button
|
|
62
|
+
await page.click('button:has-text("Export")');
|
|
63
|
+
|
|
64
|
+
// Set up download listener
|
|
65
|
+
const [download] = await Promise.all([
|
|
66
|
+
page.waitForEvent('download'),
|
|
67
|
+
page.click('button:has-text("Export CSV")'),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// Verify download
|
|
71
|
+
expect(download.suggestedFilename()).toMatch(/cybermem-audit-.*\.csv/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should download JSON file when clicking Export JSON', async ({ page }) => {
|
|
75
|
+
await page.waitForSelector('text=Audit Log');
|
|
76
|
+
|
|
77
|
+
// Click export button
|
|
78
|
+
await page.click('button:has-text("Export")');
|
|
79
|
+
|
|
80
|
+
// Set up download listener
|
|
81
|
+
const [download] = await Promise.all([
|
|
82
|
+
page.waitForEvent('download'),
|
|
83
|
+
page.click('button:has-text("Export JSON")'),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Verify download
|
|
87
|
+
expect(download.suggestedFilename()).toMatch(/cybermem-audit-.*\.json/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('CSV export should contain correct headers', async ({ page }) => {
|
|
91
|
+
await page.waitForSelector('text=Audit Log');
|
|
92
|
+
await page.click('button:has-text("Export")');
|
|
93
|
+
|
|
94
|
+
const [download] = await Promise.all([
|
|
95
|
+
page.waitForEvent('download'),
|
|
96
|
+
page.click('button:has-text("Export CSV")'),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
// Read file content
|
|
100
|
+
const content = await download.path();
|
|
101
|
+
const fs = await import('fs');
|
|
102
|
+
const csvContent = fs.readFileSync(content!, 'utf-8');
|
|
103
|
+
|
|
104
|
+
// Verify headers
|
|
105
|
+
expect(csvContent).toContain('Timestamp');
|
|
106
|
+
expect(csvContent).toContain('Client');
|
|
107
|
+
expect(csvContent).toContain('Operation');
|
|
108
|
+
expect(csvContent).toContain('Status');
|
|
109
|
+
expect(csvContent).toContain('Description');
|
|
110
|
+
});
|
|
111
|
+
});
|
package/e2e/auth.spec.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
import { expect, test } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
test.describe('Dashboard Authentication', () => {
|
|
5
|
+
|
|
6
|
+
test('should allow login with correct password', async ({ page }) => {
|
|
7
|
+
await page.goto('/');
|
|
8
|
+
|
|
9
|
+
// If already logged in, clear cookies (shouldn't happen in incognito/test env usually, but good practice if state is preserved)
|
|
10
|
+
// Check for login input
|
|
11
|
+
const loginInput = page.getByPlaceholder('Enter admin password');
|
|
12
|
+
|
|
13
|
+
if (await loginInput.isVisible()) {
|
|
14
|
+
await loginInput.fill('admin');
|
|
15
|
+
await page.getByRole('button', { name: 'Login' }).click();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Verification
|
|
19
|
+
await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible({ timeout: 10000 });
|
|
20
|
+
await expect(page.getByText('Memory Records')).toBeVisible();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should show error on wrong password', async ({ page }) => {
|
|
24
|
+
await page.goto('/');
|
|
25
|
+
|
|
26
|
+
// Ensure we are logged out or looking at login screen.
|
|
27
|
+
// Note: This assumes clean state for each test if fullyParallel is true but workers separate context.
|
|
28
|
+
// Playwright creates fresh context for each test by default.
|
|
29
|
+
|
|
30
|
+
const loginInput = page.getByPlaceholder('Enter password');
|
|
31
|
+
if (await loginInput.isVisible()) {
|
|
32
|
+
await loginInput.fill('wrongpassword');
|
|
33
|
+
await page.getByRole('button', { name: 'Login' }).click();
|
|
34
|
+
await expect(page.getByText('Invalid password')).toBeVisible();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
// Shared mock setup - mocks BEFORE page load
|
|
4
|
+
async function setupMocksForLocalMode(page: any) {
|
|
5
|
+
await page.route('**/api/settings', async (route: any) => {
|
|
6
|
+
await route.fulfill({
|
|
7
|
+
status: 200,
|
|
8
|
+
contentType: 'application/json',
|
|
9
|
+
body: JSON.stringify({
|
|
10
|
+
apiKey: 'not-set',
|
|
11
|
+
endpoint: 'http://localhost:8626',
|
|
12
|
+
isManaged: true
|
|
13
|
+
})
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
await page.route('**/api/metrics*', async (route: any) => {
|
|
17
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
|
|
18
|
+
});
|
|
19
|
+
await page.route('**/api/audit-logs*', async (route: any) => {
|
|
20
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ logs: [] }) });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function setupMocksForRemoteMode(page: any) {
|
|
25
|
+
await page.route('**/api/settings', async (route: any) => {
|
|
26
|
+
await route.fulfill({
|
|
27
|
+
status: 200,
|
|
28
|
+
contentType: 'application/json',
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
apiKey: 'sk-remote-key-123',
|
|
31
|
+
endpoint: 'https://remote-rpi.local/mcp',
|
|
32
|
+
isManaged: false
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
await page.route('**/api/metrics*', async (route: any) => {
|
|
37
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
|
|
38
|
+
});
|
|
39
|
+
await page.route('**/api/audit-logs*', async (route: any) => {
|
|
40
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ logs: [] }) });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function login(page: any) {
|
|
45
|
+
const passwordInput = page.getByPlaceholder('Enter admin password');
|
|
46
|
+
await expect(passwordInput).toBeVisible();
|
|
47
|
+
await passwordInput.fill('admin');
|
|
48
|
+
await page.keyboard.press('Enter');
|
|
49
|
+
await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible();
|
|
50
|
+
|
|
51
|
+
// Dismiss password warning modal if it appears
|
|
52
|
+
const dontShowAgainButton = page.getByRole('button', { name: "Don't show again" });
|
|
53
|
+
if (await dontShowAgainButton.isVisible({ timeout: 1500 }).catch(() => false)) {
|
|
54
|
+
await dontShowAgainButton.click();
|
|
55
|
+
await page.waitForTimeout(500);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test.describe('Dashboard Configuration UI', () => {
|
|
60
|
+
|
|
61
|
+
test('Local Mode: shows Local Mode Active and hides API Key', async ({ page }) => {
|
|
62
|
+
await setupMocksForLocalMode(page);
|
|
63
|
+
await page.goto('http://localhost:3000');
|
|
64
|
+
await login(page);
|
|
65
|
+
|
|
66
|
+
// Open MCP Config Modal
|
|
67
|
+
await page.getByRole('button', { name: 'Connect MCP' }).click();
|
|
68
|
+
|
|
69
|
+
// Check for Local Mode indicator
|
|
70
|
+
await expect(page.getByText('Local Mode Active')).toBeVisible();
|
|
71
|
+
|
|
72
|
+
// Master API Key should NOT be visible in local mode
|
|
73
|
+
await expect(page.getByText('Master API Key')).not.toBeVisible();
|
|
74
|
+
|
|
75
|
+
// Code block should show stdio command with npx @cybermem/mcp-core
|
|
76
|
+
await page.getByRole('button', { name: 'Gemini CLI' }).click();
|
|
77
|
+
const codeBlock = page.locator('pre');
|
|
78
|
+
await expect(codeBlock).toContainText('gemini mcp add cybermem npx @cybermem/mcp-core');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Remote Mode: shows API Key management', async ({ page }) => {
|
|
82
|
+
await setupMocksForRemoteMode(page);
|
|
83
|
+
await page.goto('http://localhost:3000');
|
|
84
|
+
await login(page);
|
|
85
|
+
|
|
86
|
+
// Open MCP Config Modal
|
|
87
|
+
await page.getByRole('button', { name: 'Connect MCP' }).click();
|
|
88
|
+
|
|
89
|
+
// Master API Key should be visible
|
|
90
|
+
await expect(page.getByText('Master API Key')).toBeVisible();
|
|
91
|
+
|
|
92
|
+
// Gemini CLI should have header with API key
|
|
93
|
+
await page.getByRole('button', { name: 'Gemini CLI' }).click();
|
|
94
|
+
const codeBlock = page.locator('pre');
|
|
95
|
+
await expect(codeBlock).toContainText('--header');
|
|
96
|
+
await expect(codeBlock).toContainText('x-api-key');
|
|
97
|
+
});
|
|
98
|
+
});
|