@cybermem/dashboard 0.1.0 → 0.5.1

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.
@@ -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, Save, Server, Settings, Shield, X } from "lucide-react"
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
- // Try to get key from local storage first (simulating persistence)
33
+ setIsLoading(true)
34
34
  const localKey = localStorage.getItem("om_api_key")
35
- if (localKey) {
36
- setApiKey(localKey)
37
- fetch("/api/settings")
38
- .then(res => res.json())
39
- .then(data => {
40
- let srvEndpoint = data.endpoint
41
- if (srvEndpoint.includes('localhost') && typeof window !== "undefined" && !window.location.hostname.includes('localhost')) {
42
- srvEndpoint = `${window.location.protocol}//${window.location.hostname}:8080`
43
- }
44
- setEndpoint(srvEndpoint)
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
- let srvEndpoint = data.endpoint
54
- if (srvEndpoint.includes('localhost') && typeof window !== "undefined" && !window.location.hostname.includes('localhost')) {
55
- srvEndpoint = `${window.location.protocol}//${window.location.hostname}:8080`
56
- }
57
- setEndpoint(srvEndpoint)
58
- setIsLoading(false)
59
- })
60
- .catch(err => {
61
- console.error("Failed to fetch settings:", err)
62
- setIsLoading(false)
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
- 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"
104
- style={{
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 drop-shadow-[0_0_5px_rgba(255,255,255,0.3)]" />
97
+ <Settings className="w-5 h-5 text-white shadow-lg" />
118
98
  </div>
119
- <h2 className="text-xl font-semibold text-white text-shadow-sm">Settings</h2>
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 text-shadow-sm">
136
- <Shield className="w-5 h-5 text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.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" className="text-neutral-200">Admin Password</Label>
142
- <div className="flex gap-2">
143
- <div className="relative flex-1">
144
- <Input
145
- id="admin-password"
146
- value={adminPassword}
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 text-shadow-sm">
168
- <Key className="w-5 h-5 text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.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" className="text-neutral-200">Master API Key</Label>
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
- id="api-key"
178
- value={apiKey || "sk-not-generated-yet"}
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
- size="icon"
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
- {showRegenConfirm ? (
205
- <div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-200">
206
- <span className="text-xs text-red-400 font-medium">Warning: This will disconnect all existing clients.</span>
207
- <Input
208
- value={regenInputValue}
209
- onChange={(e) => setRegenInputValue(e.target.value)}
210
- placeholder="Type 'agree'"
211
- className="h-8 w-28 bg-red-500/10 border-red-500/30 text-red-200 text-xs placeholder:text-red-500/30 focus-visible:border-red-500/50"
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" className="text-neutral-200">Server Endpoint</Label>
249
- <div className="flex gap-2">
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-2 border-t border-white/10">
261
- <Button
262
- variant="destructive"
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 text-shadow-sm">
284
- <Server className="w-5 h-5 text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.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
- <span className="text-xs uppercase tracking-wider text-neutral-500 font-semibold">Version</span>
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
- onClick={onClose}
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
+ });
@@ -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
+ });