yugbirla commited on
Commit
7b53c85
·
1 Parent(s): 98a8ef8

Add hidden admin panel with role protected admin APIs

Browse files
app/main.py CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  from app.product.source_viewer import get_source_details, get_source_html
2
  from app.deployment.hf_status import get_home_html, get_product_app_html
3
  from app.deployment.hf_status import get_product_app_html
@@ -689,3 +696,66 @@ def document_source_view(
689
  page=page,
690
  chunk_id=chunk_id
691
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from fastapi import Request, Query
3
+ from fastapi.responses import HTMLResponse
4
+ from app.product.auth_service import get_current_user_from_request, require_admin_user, dev_login_user
5
+ from app.product.admin_service import get_admin_status, get_admin_users, get_admin_documents, get_admin_conversations, get_admin_system_summary
6
+ from app.product.admin_ui import get_admin_panel_html
7
+
8
  from app.product.source_viewer import get_source_details, get_source_html
9
  from app.deployment.hf_status import get_home_html, get_product_app_html
10
  from app.deployment.hf_status import get_product_app_html
 
696
  page=page,
697
  chunk_id=chunk_id
698
  )
699
+
700
+
701
+ # Auth foundation endpoints
702
+
703
+ @app.get("/auth/me")
704
+ def auth_me(request: Request):
705
+ return get_current_user_from_request(request)
706
+
707
+
708
+ @app.get("/auth/dev-login")
709
+ def auth_dev_login(
710
+ email: str = Query(..., min_length=3),
711
+ name: str = Query(None)
712
+ ):
713
+ return dev_login_user(email=email, name=name)
714
+
715
+
716
+ # Hidden admin panel UI
717
+
718
+ @app.get("/admin", response_class=HTMLResponse)
719
+ def admin_panel_page():
720
+ return get_admin_panel_html()
721
+
722
+
723
+ # Admin API endpoints
724
+
725
+ @app.get("/admin/status")
726
+ def admin_status(request: Request):
727
+ current_admin = require_admin_user(request)
728
+ return get_admin_status(current_admin=current_admin)
729
+
730
+
731
+ @app.get("/admin/users")
732
+ def admin_users(
733
+ request: Request,
734
+ limit: int = Query(100, ge=1, le=500)
735
+ ):
736
+ require_admin_user(request)
737
+ return get_admin_users(limit=limit)
738
+
739
+
740
+ @app.get("/admin/documents")
741
+ def admin_documents(
742
+ request: Request,
743
+ limit: int = Query(100, ge=1, le=500)
744
+ ):
745
+ require_admin_user(request)
746
+ return get_admin_documents(limit=limit)
747
+
748
+
749
+ @app.get("/admin/conversations")
750
+ def admin_conversations(
751
+ request: Request,
752
+ limit: int = Query(100, ge=1, le=500)
753
+ ):
754
+ require_admin_user(request)
755
+ return get_admin_conversations(limit=limit)
756
+
757
+
758
+ @app.get("/admin/system")
759
+ def admin_system(request: Request):
760
+ require_admin_user(request)
761
+ return get_admin_system_summary()
app/product/admin_service.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from typing import Dict, Any
3
+
4
+ from app.product.product_db import (
5
+ get_database_status,
6
+ list_users,
7
+ list_documents,
8
+ list_conversations
9
+ )
10
+
11
+
12
+ def get_admin_status(current_admin: Dict[str, Any]) -> Dict[str, Any]:
13
+ return {
14
+ "status": "ok",
15
+ "message": "Admin backend is available.",
16
+ "admin": {
17
+ "email": current_admin.get("email"),
18
+ "role": current_admin.get("role")
19
+ },
20
+ "database": get_database_status()
21
+ }
22
+
23
+
24
+ def get_admin_users(limit: int = 100) -> Dict[str, Any]:
25
+ users = list_users(limit=limit)
26
+
27
+ return {
28
+ "count": len(users),
29
+ "users": users
30
+ }
31
+
32
+
33
+ def get_admin_documents(limit: int = 100) -> Dict[str, Any]:
34
+ documents = list_documents(limit=limit)
35
+
36
+ return {
37
+ "count": len(documents),
38
+ "documents": documents
39
+ }
40
+
41
+
42
+ def get_admin_conversations(limit: int = 100) -> Dict[str, Any]:
43
+ conversations = list_conversations(limit=limit)
44
+
45
+ return {
46
+ "count": len(conversations),
47
+ "conversations": conversations
48
+ }
49
+
50
+
51
+ def get_admin_system_summary() -> Dict[str, Any]:
52
+ db = get_database_status()
53
+
54
+ return {
55
+ "status": "ok",
56
+ "database": db,
57
+ "notes": [
58
+ "Admin tools are separated from the normal user app.",
59
+ "Normal users should not see API docs or GraphRAG console links.",
60
+ "Admin APIs are protected by backend role checks."
61
+ ]
62
+ }
app/product/admin_ui.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ def get_admin_panel_html() -> str:
3
+ return """
4
+ <!DOCTYPE html>
5
+ <html>
6
+ <head>
7
+ <title>GraphResearcher Admin</title>
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+
10
+ <style>
11
+ * { box-sizing: border-box; }
12
+
13
+ body {
14
+ margin: 0;
15
+ font-family: Inter, Arial, sans-serif;
16
+ background: #f8fafc;
17
+ color: #0f172a;
18
+ }
19
+
20
+ .layout {
21
+ display: grid;
22
+ grid-template-columns: 280px 1fr;
23
+ min-height: 100vh;
24
+ }
25
+
26
+ .sidebar {
27
+ background: #0f172a;
28
+ color: white;
29
+ padding: 20px;
30
+ }
31
+
32
+ .brand {
33
+ font-weight: 900;
34
+ font-size: 24px;
35
+ margin-bottom: 6px;
36
+ }
37
+
38
+ .sub {
39
+ color: #94a3b8;
40
+ font-size: 13px;
41
+ margin-bottom: 22px;
42
+ }
43
+
44
+ input {
45
+ width: 100%;
46
+ padding: 11px;
47
+ border-radius: 9px;
48
+ border: 1px solid #cbd5e1;
49
+ margin-bottom: 10px;
50
+ }
51
+
52
+ button {
53
+ border: none;
54
+ background: #2563eb;
55
+ color: white;
56
+ border-radius: 10px;
57
+ padding: 11px 13px;
58
+ cursor: pointer;
59
+ font-weight: 800;
60
+ width: 100%;
61
+ margin-bottom: 9px;
62
+ }
63
+
64
+ button:hover { background: #1d4ed8; }
65
+ button.dark { background: #334155; }
66
+ button.green { background: #059669; }
67
+ button.red { background: #dc2626; }
68
+
69
+ .main {
70
+ padding: 26px;
71
+ }
72
+
73
+ .top {
74
+ display: flex;
75
+ justify-content: space-between;
76
+ align-items: center;
77
+ gap: 16px;
78
+ margin-bottom: 20px;
79
+ }
80
+
81
+ .status-pill {
82
+ display: inline-block;
83
+ padding: 7px 11px;
84
+ background: #e0f2fe;
85
+ color: #075985;
86
+ border-radius: 999px;
87
+ font-size: 13px;
88
+ font-weight: 800;
89
+ }
90
+
91
+ .grid {
92
+ display: grid;
93
+ grid-template-columns: repeat(4, 1fr);
94
+ gap: 14px;
95
+ margin-bottom: 20px;
96
+ }
97
+
98
+ .card {
99
+ background: white;
100
+ border: 1px solid #e5e7eb;
101
+ border-radius: 18px;
102
+ padding: 18px;
103
+ box-shadow: 0 1px 4px rgba(0,0,0,0.04);
104
+ }
105
+
106
+ .card h3 {
107
+ margin-top: 0;
108
+ }
109
+
110
+ .metric {
111
+ font-size: 30px;
112
+ font-weight: 900;
113
+ color: #1d4ed8;
114
+ }
115
+
116
+ pre {
117
+ white-space: pre-wrap;
118
+ word-break: break-word;
119
+ background: #0f172a;
120
+ color: #e5e7eb;
121
+ padding: 16px;
122
+ border-radius: 14px;
123
+ max-height: 560px;
124
+ overflow: auto;
125
+ }
126
+
127
+ table {
128
+ width: 100%;
129
+ border-collapse: collapse;
130
+ background: white;
131
+ border-radius: 12px;
132
+ overflow: hidden;
133
+ }
134
+
135
+ th, td {
136
+ border-bottom: 1px solid #e5e7eb;
137
+ text-align: left;
138
+ padding: 10px;
139
+ font-size: 13px;
140
+ }
141
+
142
+ th {
143
+ background: #f1f5f9;
144
+ font-weight: 900;
145
+ }
146
+
147
+ .warning {
148
+ background: #fff7ed;
149
+ border: 1px solid #fed7aa;
150
+ color: #9a3412;
151
+ border-radius: 12px;
152
+ padding: 12px;
153
+ margin-bottom: 16px;
154
+ font-size: 14px;
155
+ }
156
+
157
+ .hidden {
158
+ display: none;
159
+ }
160
+
161
+ @media(max-width: 950px) {
162
+ .layout {
163
+ grid-template-columns: 1fr;
164
+ }
165
+
166
+ .sidebar {
167
+ position: static;
168
+ }
169
+
170
+ .grid {
171
+ grid-template-columns: 1fr;
172
+ }
173
+ }
174
+ </style>
175
+ </head>
176
+
177
+ <body>
178
+ <div class="layout">
179
+ <aside class="sidebar">
180
+ <div class="brand">Admin Panel</div>
181
+ <div class="sub">GraphResearcher internal tools</div>
182
+
183
+ <label>Admin email</label>
184
+ <input id="adminEmail" value="2006yugb@gmail.com">
185
+
186
+ <button onclick="saveAdmin()">Use Admin Email</button>
187
+ <button onclick="loadStatus()" class="green">Dashboard</button>
188
+ <button onclick="loadUsers()">Users</button>
189
+ <button onclick="loadDocuments()">Documents</button>
190
+ <button onclick="loadConversations()">Conversations</button>
191
+ <button onclick="loadSystem()">System</button>
192
+ <button onclick="loadHealth()" class="dark">Deployment Health</button>
193
+ <button onclick="loadLLM()" class="dark">LLM Status</button>
194
+
195
+ <hr style="border-color: rgba(255,255,255,0.18); margin: 18px 0;">
196
+
197
+ <button onclick="window.open('/app','_blank')" class="dark">Open User App</button>
198
+ <button onclick="window.open('/docs','_blank')" class="dark">Open API Docs</button>
199
+ <button onclick="window.open('/graphrag-demo','_blank')" class="dark">Open GraphRAG Console</button>
200
+ <button onclick="logout()" class="red">Clear Admin Session</button>
201
+ </aside>
202
+
203
+ <main class="main">
204
+ <div class="top">
205
+ <div>
206
+ <h1>GraphResearcher Admin</h1>
207
+ <p style="color:#64748b;margin-top:-8px;">Hidden admin workspace for monitoring users, documents, system health, and developer tools.</p>
208
+ </div>
209
+ <span id="statusPill" class="status-pill">Not checked</span>
210
+ </div>
211
+
212
+ <div class="warning">
213
+ This page is hidden from the normal user app. The backend APIs still check admin role using the admin email.
214
+ Later Google OAuth will replace this temporary email-header login.
215
+ </div>
216
+
217
+ <div id="dashboardCards" class="grid">
218
+ <div class="card">
219
+ <h3>Users</h3>
220
+ <div id="usersMetric" class="metric">-</div>
221
+ </div>
222
+ <div class="card">
223
+ <h3>Documents</h3>
224
+ <div id="docsMetric" class="metric">-</div>
225
+ </div>
226
+ <div class="card">
227
+ <h3>Conversations</h3>
228
+ <div id="convMetric" class="metric">-</div>
229
+ </div>
230
+ <div class="card">
231
+ <h3>Status</h3>
232
+ <div id="systemMetric" class="metric">-</div>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="card">
237
+ <h3 id="outputTitle">Output</h3>
238
+ <div id="tableOutput"></div>
239
+ <pre id="rawOutput">{}</pre>
240
+ </div>
241
+ </main>
242
+ </div>
243
+
244
+ <script>
245
+ function getAdminEmail() {
246
+ return localStorage.getItem("graphrag_admin_email") || document.getElementById("adminEmail").value.trim();
247
+ }
248
+
249
+ function saveAdmin() {
250
+ const email = document.getElementById("adminEmail").value.trim();
251
+ localStorage.setItem("graphrag_admin_email", email);
252
+ setStatus("Admin email saved");
253
+ loadStatus();
254
+ }
255
+
256
+ function logout() {
257
+ localStorage.removeItem("graphrag_admin_email");
258
+ setStatus("Admin cleared");
259
+ document.getElementById("rawOutput").textContent = "{}";
260
+ document.getElementById("tableOutput").innerHTML = "";
261
+ }
262
+
263
+ function headers() {
264
+ return {
265
+ "X-User-Email": getAdminEmail(),
266
+ "X-User-Name": "Admin"
267
+ };
268
+ }
269
+
270
+ function setStatus(text) {
271
+ document.getElementById("statusPill").textContent = text;
272
+ }
273
+
274
+ function showRaw(title, data) {
275
+ document.getElementById("outputTitle").textContent = title;
276
+ document.getElementById("rawOutput").textContent = JSON.stringify(data, null, 2);
277
+ }
278
+
279
+ async function fetchJson(url, useAdminHeaders = true) {
280
+ const response = await fetch(url, {
281
+ headers: useAdminHeaders ? headers() : {}
282
+ });
283
+
284
+ const data = await response.json();
285
+
286
+ if (!response.ok) {
287
+ throw new Error(JSON.stringify(data));
288
+ }
289
+
290
+ return data;
291
+ }
292
+
293
+ function renderTable(rows) {
294
+ const box = document.getElementById("tableOutput");
295
+
296
+ if (!rows || rows.length === 0) {
297
+ box.innerHTML = "<p>No rows.</p>";
298
+ return;
299
+ }
300
+
301
+ const columns = Object.keys(rows[0]).slice(0, 8);
302
+
303
+ let html = "<table><thead><tr>";
304
+
305
+ columns.forEach(col => {
306
+ html += `<th>${col}</th>`;
307
+ });
308
+
309
+ html += "</tr></thead><tbody>";
310
+
311
+ rows.forEach(row => {
312
+ html += "<tr>";
313
+ columns.forEach(col => {
314
+ html += `<td>${String(row[col] ?? "").slice(0, 80)}</td>`;
315
+ });
316
+ html += "</tr>";
317
+ });
318
+
319
+ html += "</tbody></table>";
320
+ box.innerHTML = html;
321
+ }
322
+
323
+ async function loadStatus() {
324
+ try {
325
+ setStatus("Loading...");
326
+ const data = await fetchJson("/admin/status");
327
+
328
+ showRaw("Admin Status", data);
329
+
330
+ const counts = data.database?.table_counts || {};
331
+ document.getElementById("usersMetric").textContent = counts.users ?? "-";
332
+ document.getElementById("docsMetric").textContent = counts.user_documents ?? "-";
333
+ document.getElementById("convMetric").textContent = counts.conversations ?? "-";
334
+ document.getElementById("systemMetric").textContent = data.status || "ok";
335
+
336
+ document.getElementById("tableOutput").innerHTML = "";
337
+ setStatus("Admin verified");
338
+ } catch (error) {
339
+ setStatus("Access denied");
340
+ showRaw("Error", { error: error.message });
341
+ }
342
+ }
343
+
344
+ async function loadUsers() {
345
+ try {
346
+ setStatus("Loading users...");
347
+ const data = await fetchJson("/admin/users");
348
+ showRaw("Users", data);
349
+ renderTable(data.users || []);
350
+ setStatus("Users loaded");
351
+ } catch (error) {
352
+ setStatus("Error");
353
+ showRaw("Error", { error: error.message });
354
+ }
355
+ }
356
+
357
+ async function loadDocuments() {
358
+ try {
359
+ setStatus("Loading documents...");
360
+ const data = await fetchJson("/admin/documents");
361
+ showRaw("Documents", data);
362
+ renderTable(data.documents || []);
363
+ setStatus("Documents loaded");
364
+ } catch (error) {
365
+ setStatus("Error");
366
+ showRaw("Error", { error: error.message });
367
+ }
368
+ }
369
+
370
+ async function loadConversations() {
371
+ try {
372
+ setStatus("Loading conversations...");
373
+ const data = await fetchJson("/admin/conversations");
374
+ showRaw("Conversations", data);
375
+ renderTable(data.conversations || []);
376
+ setStatus("Conversations loaded");
377
+ } catch (error) {
378
+ setStatus("Error");
379
+ showRaw("Error", { error: error.message });
380
+ }
381
+ }
382
+
383
+ async function loadSystem() {
384
+ try {
385
+ setStatus("Loading system...");
386
+ const data = await fetchJson("/admin/system");
387
+ showRaw("System", data);
388
+ document.getElementById("tableOutput").innerHTML = "";
389
+ setStatus("System loaded");
390
+ } catch (error) {
391
+ setStatus("Error");
392
+ showRaw("Error", { error: error.message });
393
+ }
394
+ }
395
+
396
+ async function loadHealth() {
397
+ try {
398
+ setStatus("Loading health...");
399
+ const data = await fetchJson("/deployment/health", false);
400
+ showRaw("Deployment Health", data);
401
+ document.getElementById("tableOutput").innerHTML = "";
402
+ setStatus("Health loaded");
403
+ } catch (error) {
404
+ setStatus("Error");
405
+ showRaw("Error", { error: error.message });
406
+ }
407
+ }
408
+
409
+ async function loadLLM() {
410
+ try {
411
+ setStatus("Loading LLM...");
412
+ const data = await fetchJson("/llm/status", false);
413
+ showRaw("LLM Status", data);
414
+ document.getElementById("tableOutput").innerHTML = "";
415
+ setStatus("LLM loaded");
416
+ } catch (error) {
417
+ setStatus("Error");
418
+ showRaw("Error", { error: error.message });
419
+ }
420
+ }
421
+
422
+ document.getElementById("adminEmail").value = getAdminEmail();
423
+ loadStatus();
424
+ </script>
425
+ </body>
426
+ </html>
427
+ """
app/product/auth_service.py CHANGED
@@ -1,4 +1,5 @@
1
- import os
 
2
  from typing import Dict, Any, Optional
3
 
4
  from fastapi import Request, HTTPException
@@ -64,7 +65,6 @@ def get_current_user_from_request(request: Request) -> Dict[str, Any]:
64
  )
65
 
66
  user["authenticated"] = True
67
-
68
  return user
69
 
70
 
@@ -74,7 +74,7 @@ def require_authenticated_user(request: Request) -> Dict[str, Any]:
74
  if not user.get("authenticated"):
75
  raise HTTPException(
76
  status_code=401,
77
- detail="Authentication required. For dev testing, pass X-User-Email header."
78
  )
79
 
80
  return user
@@ -96,10 +96,7 @@ def dev_login_user(email: str, name: Optional[str] = None) -> Dict[str, Any]:
96
  email = normalize_email(email)
97
 
98
  if not email:
99
- raise HTTPException(
100
- status_code=400,
101
- detail="email is required"
102
- )
103
 
104
  role = infer_role(email)
105
  user_id = make_user_id(email)
 
1
+
2
+ import os
3
  from typing import Dict, Any, Optional
4
 
5
  from fastapi import Request, HTTPException
 
65
  )
66
 
67
  user["authenticated"] = True
 
68
  return user
69
 
70
 
 
74
  if not user.get("authenticated"):
75
  raise HTTPException(
76
  status_code=401,
77
+ detail="Authentication required."
78
  )
79
 
80
  return user
 
96
  email = normalize_email(email)
97
 
98
  if not email:
99
+ raise HTTPException(status_code=400, detail="email is required")
 
 
 
100
 
101
  role = infer_role(email)
102
  user_id = make_user_id(email)
scripts/phase31_hidden_admin_panel.py ADDED
@@ -0,0 +1,952 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ # Clean BOM
4
+ for path in Path("app").rglob("*.py"):
5
+ text = path.read_text(encoding="utf-8-sig")
6
+ text = text.replace("\ufeff", "")
7
+ path.write_text(text, encoding="utf-8")
8
+
9
+ print("BOM cleanup completed.")
10
+
11
+ # =====================================================
12
+ # 1. Minimal product_db fallback if missing
13
+ # =====================================================
14
+
15
+ product_db_path = Path("app/product/product_db.py")
16
+
17
+ if not product_db_path.exists():
18
+ product_db_path.write_text(r'''
19
+ import os
20
+ import sqlite3
21
+ from pathlib import Path
22
+ from datetime import datetime, timezone
23
+ from typing import Dict, Any, List, Optional
24
+
25
+ from app.core.config import settings
26
+
27
+
28
+ def utc_now() -> str:
29
+ return datetime.now(timezone.utc).isoformat()
30
+
31
+
32
+ def get_database_path() -> Path:
33
+ env_path = os.getenv("APP_DATABASE_PATH")
34
+
35
+ if env_path:
36
+ db_path = Path(env_path)
37
+ else:
38
+ db_path = Path(settings.PROCESSED_DIR).parent / "product_app.sqlite3"
39
+
40
+ db_path.parent.mkdir(parents=True, exist_ok=True)
41
+ return db_path
42
+
43
+
44
+ def get_connection():
45
+ conn = sqlite3.connect(str(get_database_path()))
46
+ conn.row_factory = sqlite3.Row
47
+ return conn
48
+
49
+
50
+ def rows_to_dicts(rows):
51
+ return [dict(row) for row in rows]
52
+
53
+
54
+ def init_product_database() -> Dict[str, Any]:
55
+ conn = get_connection()
56
+ cur = conn.cursor()
57
+
58
+ cur.execute("""
59
+ CREATE TABLE IF NOT EXISTS users (
60
+ user_id TEXT PRIMARY KEY,
61
+ email TEXT UNIQUE NOT NULL,
62
+ name TEXT,
63
+ role TEXT NOT NULL DEFAULT 'user',
64
+ auth_provider TEXT DEFAULT 'local',
65
+ is_active INTEGER DEFAULT 1,
66
+ created_at TEXT NOT NULL,
67
+ last_login_at TEXT
68
+ )
69
+ """)
70
+
71
+ cur.execute("""
72
+ CREATE TABLE IF NOT EXISTS user_documents (
73
+ document_id TEXT PRIMARY KEY,
74
+ owner_user_id TEXT,
75
+ source_file_name TEXT,
76
+ upload_status TEXT DEFAULT 'uploaded',
77
+ index_status TEXT DEFAULT 'not_indexed',
78
+ graph_status TEXT DEFAULT 'not_built',
79
+ chunk_count INTEGER DEFAULT 0,
80
+ entity_count INTEGER DEFAULT 0,
81
+ relation_count INTEGER DEFAULT 0,
82
+ created_at TEXT NOT NULL
83
+ )
84
+ """)
85
+
86
+ cur.execute("""
87
+ CREATE TABLE IF NOT EXISTS conversations (
88
+ conversation_id TEXT PRIMARY KEY,
89
+ owner_user_id TEXT,
90
+ document_id TEXT,
91
+ title TEXT,
92
+ created_at TEXT NOT NULL,
93
+ updated_at TEXT NOT NULL
94
+ )
95
+ """)
96
+
97
+ cur.execute("""
98
+ CREATE TABLE IF NOT EXISTS messages (
99
+ message_id TEXT PRIMARY KEY,
100
+ conversation_id TEXT,
101
+ role TEXT,
102
+ content TEXT,
103
+ created_at TEXT NOT NULL,
104
+ metadata_json TEXT
105
+ )
106
+ """)
107
+
108
+ conn.commit()
109
+ conn.close()
110
+
111
+ return {
112
+ "status": "success",
113
+ "database_path": str(get_database_path())
114
+ }
115
+
116
+
117
+ def get_database_status() -> Dict[str, Any]:
118
+ init_product_database()
119
+
120
+ conn = get_connection()
121
+ cur = conn.cursor()
122
+
123
+ tables = ["users", "user_documents", "conversations", "messages"]
124
+ counts = {}
125
+
126
+ for table in tables:
127
+ cur.execute(f"SELECT COUNT(*) AS count FROM {table}")
128
+ counts[table] = int(cur.fetchone()["count"])
129
+
130
+ conn.close()
131
+
132
+ return {
133
+ "status": "healthy",
134
+ "database_path": str(get_database_path()),
135
+ "table_counts": counts
136
+ }
137
+
138
+
139
+ def upsert_user(
140
+ user_id: str,
141
+ email: str,
142
+ name: Optional[str] = None,
143
+ role: str = "user",
144
+ auth_provider: str = "local",
145
+ avatar_url: Optional[str] = None
146
+ ):
147
+ init_product_database()
148
+
149
+ now = utc_now()
150
+
151
+ conn = get_connection()
152
+ cur = conn.cursor()
153
+
154
+ cur.execute("""
155
+ INSERT INTO users (user_id, email, name, role, auth_provider, is_active, created_at, last_login_at)
156
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)
157
+ ON CONFLICT(email) DO UPDATE SET
158
+ name = excluded.name,
159
+ role = excluded.role,
160
+ auth_provider = excluded.auth_provider,
161
+ last_login_at = excluded.last_login_at
162
+ """, (user_id, email, name, role, auth_provider, now, now))
163
+
164
+ conn.commit()
165
+
166
+ cur.execute("SELECT * FROM users WHERE email = ?", (email,))
167
+ user = dict(cur.fetchone())
168
+
169
+ conn.close()
170
+ return user
171
+
172
+
173
+ def list_users(limit: int = 100):
174
+ init_product_database()
175
+
176
+ conn = get_connection()
177
+ cur = conn.cursor()
178
+
179
+ cur.execute("""
180
+ SELECT user_id, email, name, role, auth_provider, is_active, created_at, last_login_at
181
+ FROM users
182
+ ORDER BY created_at DESC
183
+ LIMIT ?
184
+ """, (limit,))
185
+
186
+ rows = rows_to_dicts(cur.fetchall())
187
+ conn.close()
188
+ return rows
189
+
190
+
191
+ def list_documents(limit: int = 100):
192
+ init_product_database()
193
+
194
+ conn = get_connection()
195
+ cur = conn.cursor()
196
+
197
+ cur.execute("""
198
+ SELECT *
199
+ FROM user_documents
200
+ ORDER BY created_at DESC
201
+ LIMIT ?
202
+ """, (limit,))
203
+
204
+ rows = rows_to_dicts(cur.fetchall())
205
+ conn.close()
206
+ return rows
207
+
208
+
209
+ def list_conversations(limit: int = 100):
210
+ init_product_database()
211
+
212
+ conn = get_connection()
213
+ cur = conn.cursor()
214
+
215
+ cur.execute("""
216
+ SELECT *
217
+ FROM conversations
218
+ ORDER BY updated_at DESC
219
+ LIMIT ?
220
+ """, (limit,))
221
+
222
+ rows = rows_to_dicts(cur.fetchall())
223
+ conn.close()
224
+ return rows
225
+ ''', encoding="utf-8")
226
+ print("Created fallback product_db.py")
227
+ else:
228
+ print("product_db.py already exists")
229
+
230
+
231
+ # =====================================================
232
+ # 2. Auth service
233
+ # =====================================================
234
+
235
+ Path("app/product/auth_service.py").write_text(r'''
236
+ import os
237
+ from typing import Dict, Any, Optional
238
+
239
+ from fastapi import Request, HTTPException
240
+
241
+ from app.product.product_db import upsert_user
242
+
243
+
244
+ DEFAULT_ADMIN_EMAILS = {
245
+ "2006yugb@gmail.com"
246
+ }
247
+
248
+
249
+ def get_admin_emails():
250
+ raw = os.getenv("ADMIN_EMAILS", "")
251
+
252
+ emails = {
253
+ email.strip().lower()
254
+ for email in raw.split(",")
255
+ if email.strip()
256
+ }
257
+
258
+ return emails | DEFAULT_ADMIN_EMAILS
259
+
260
+
261
+ def normalize_email(email: Optional[str]) -> str:
262
+ return str(email or "").strip().lower()
263
+
264
+
265
+ def make_user_id(email: str) -> str:
266
+ return "user_" + email.replace("@", "_").replace(".", "_")
267
+
268
+
269
+ def infer_role(email: str) -> str:
270
+ if normalize_email(email) in get_admin_emails():
271
+ return "admin"
272
+
273
+ return "user"
274
+
275
+
276
+ def get_current_user_from_request(request: Request) -> Dict[str, Any]:
277
+ email = normalize_email(request.headers.get("x-user-email"))
278
+ name = request.headers.get("x-user-name")
279
+
280
+ if not email:
281
+ return {
282
+ "authenticated": False,
283
+ "user_id": None,
284
+ "email": None,
285
+ "name": "Guest",
286
+ "role": "guest",
287
+ "auth_provider": "none"
288
+ }
289
+
290
+ role = infer_role(email)
291
+ user_id = make_user_id(email)
292
+
293
+ user = upsert_user(
294
+ user_id=user_id,
295
+ email=email,
296
+ name=name or email.split("@")[0],
297
+ role=role,
298
+ auth_provider="header_dev"
299
+ )
300
+
301
+ user["authenticated"] = True
302
+ return user
303
+
304
+
305
+ def require_authenticated_user(request: Request) -> Dict[str, Any]:
306
+ user = get_current_user_from_request(request)
307
+
308
+ if not user.get("authenticated"):
309
+ raise HTTPException(
310
+ status_code=401,
311
+ detail="Authentication required."
312
+ )
313
+
314
+ return user
315
+
316
+
317
+ def require_admin_user(request: Request) -> Dict[str, Any]:
318
+ user = require_authenticated_user(request)
319
+
320
+ if user.get("role") != "admin":
321
+ raise HTTPException(
322
+ status_code=403,
323
+ detail="Admin access required."
324
+ )
325
+
326
+ return user
327
+
328
+
329
+ def dev_login_user(email: str, name: Optional[str] = None) -> Dict[str, Any]:
330
+ email = normalize_email(email)
331
+
332
+ if not email:
333
+ raise HTTPException(status_code=400, detail="email is required")
334
+
335
+ role = infer_role(email)
336
+ user_id = make_user_id(email)
337
+
338
+ user = upsert_user(
339
+ user_id=user_id,
340
+ email=email,
341
+ name=name or email.split("@")[0],
342
+ role=role,
343
+ auth_provider="dev_login"
344
+ )
345
+
346
+ user["authenticated"] = True
347
+ user["dev_header_hint"] = {
348
+ "X-User-Email": email,
349
+ "X-User-Name": name or email.split("@")[0]
350
+ }
351
+
352
+ return user
353
+ ''', encoding="utf-8")
354
+
355
+
356
+ # =====================================================
357
+ # 3. Admin service
358
+ # =====================================================
359
+
360
+ Path("app/product/admin_service.py").write_text(r'''
361
+ from typing import Dict, Any
362
+
363
+ from app.product.product_db import (
364
+ get_database_status,
365
+ list_users,
366
+ list_documents,
367
+ list_conversations
368
+ )
369
+
370
+
371
+ def get_admin_status(current_admin: Dict[str, Any]) -> Dict[str, Any]:
372
+ return {
373
+ "status": "ok",
374
+ "message": "Admin backend is available.",
375
+ "admin": {
376
+ "email": current_admin.get("email"),
377
+ "role": current_admin.get("role")
378
+ },
379
+ "database": get_database_status()
380
+ }
381
+
382
+
383
+ def get_admin_users(limit: int = 100) -> Dict[str, Any]:
384
+ users = list_users(limit=limit)
385
+
386
+ return {
387
+ "count": len(users),
388
+ "users": users
389
+ }
390
+
391
+
392
+ def get_admin_documents(limit: int = 100) -> Dict[str, Any]:
393
+ documents = list_documents(limit=limit)
394
+
395
+ return {
396
+ "count": len(documents),
397
+ "documents": documents
398
+ }
399
+
400
+
401
+ def get_admin_conversations(limit: int = 100) -> Dict[str, Any]:
402
+ conversations = list_conversations(limit=limit)
403
+
404
+ return {
405
+ "count": len(conversations),
406
+ "conversations": conversations
407
+ }
408
+
409
+
410
+ def get_admin_system_summary() -> Dict[str, Any]:
411
+ db = get_database_status()
412
+
413
+ return {
414
+ "status": "ok",
415
+ "database": db,
416
+ "notes": [
417
+ "Admin tools are separated from the normal user app.",
418
+ "Normal users should not see API docs or GraphRAG console links.",
419
+ "Admin APIs are protected by backend role checks."
420
+ ]
421
+ }
422
+ ''', encoding="utf-8")
423
+
424
+
425
+ # =====================================================
426
+ # 4. Admin UI
427
+ # =====================================================
428
+
429
+ Path("app/product/admin_ui.py").write_text(r'''
430
+ def get_admin_panel_html() -> str:
431
+ return """
432
+ <!DOCTYPE html>
433
+ <html>
434
+ <head>
435
+ <title>GraphResearcher Admin</title>
436
+ <meta name="viewport" content="width=device-width, initial-scale=1">
437
+
438
+ <style>
439
+ * { box-sizing: border-box; }
440
+
441
+ body {
442
+ margin: 0;
443
+ font-family: Inter, Arial, sans-serif;
444
+ background: #f8fafc;
445
+ color: #0f172a;
446
+ }
447
+
448
+ .layout {
449
+ display: grid;
450
+ grid-template-columns: 280px 1fr;
451
+ min-height: 100vh;
452
+ }
453
+
454
+ .sidebar {
455
+ background: #0f172a;
456
+ color: white;
457
+ padding: 20px;
458
+ }
459
+
460
+ .brand {
461
+ font-weight: 900;
462
+ font-size: 24px;
463
+ margin-bottom: 6px;
464
+ }
465
+
466
+ .sub {
467
+ color: #94a3b8;
468
+ font-size: 13px;
469
+ margin-bottom: 22px;
470
+ }
471
+
472
+ input {
473
+ width: 100%;
474
+ padding: 11px;
475
+ border-radius: 9px;
476
+ border: 1px solid #cbd5e1;
477
+ margin-bottom: 10px;
478
+ }
479
+
480
+ button {
481
+ border: none;
482
+ background: #2563eb;
483
+ color: white;
484
+ border-radius: 10px;
485
+ padding: 11px 13px;
486
+ cursor: pointer;
487
+ font-weight: 800;
488
+ width: 100%;
489
+ margin-bottom: 9px;
490
+ }
491
+
492
+ button:hover { background: #1d4ed8; }
493
+ button.dark { background: #334155; }
494
+ button.green { background: #059669; }
495
+ button.red { background: #dc2626; }
496
+
497
+ .main {
498
+ padding: 26px;
499
+ }
500
+
501
+ .top {
502
+ display: flex;
503
+ justify-content: space-between;
504
+ align-items: center;
505
+ gap: 16px;
506
+ margin-bottom: 20px;
507
+ }
508
+
509
+ .status-pill {
510
+ display: inline-block;
511
+ padding: 7px 11px;
512
+ background: #e0f2fe;
513
+ color: #075985;
514
+ border-radius: 999px;
515
+ font-size: 13px;
516
+ font-weight: 800;
517
+ }
518
+
519
+ .grid {
520
+ display: grid;
521
+ grid-template-columns: repeat(4, 1fr);
522
+ gap: 14px;
523
+ margin-bottom: 20px;
524
+ }
525
+
526
+ .card {
527
+ background: white;
528
+ border: 1px solid #e5e7eb;
529
+ border-radius: 18px;
530
+ padding: 18px;
531
+ box-shadow: 0 1px 4px rgba(0,0,0,0.04);
532
+ }
533
+
534
+ .card h3 {
535
+ margin-top: 0;
536
+ }
537
+
538
+ .metric {
539
+ font-size: 30px;
540
+ font-weight: 900;
541
+ color: #1d4ed8;
542
+ }
543
+
544
+ pre {
545
+ white-space: pre-wrap;
546
+ word-break: break-word;
547
+ background: #0f172a;
548
+ color: #e5e7eb;
549
+ padding: 16px;
550
+ border-radius: 14px;
551
+ max-height: 560px;
552
+ overflow: auto;
553
+ }
554
+
555
+ table {
556
+ width: 100%;
557
+ border-collapse: collapse;
558
+ background: white;
559
+ border-radius: 12px;
560
+ overflow: hidden;
561
+ }
562
+
563
+ th, td {
564
+ border-bottom: 1px solid #e5e7eb;
565
+ text-align: left;
566
+ padding: 10px;
567
+ font-size: 13px;
568
+ }
569
+
570
+ th {
571
+ background: #f1f5f9;
572
+ font-weight: 900;
573
+ }
574
+
575
+ .warning {
576
+ background: #fff7ed;
577
+ border: 1px solid #fed7aa;
578
+ color: #9a3412;
579
+ border-radius: 12px;
580
+ padding: 12px;
581
+ margin-bottom: 16px;
582
+ font-size: 14px;
583
+ }
584
+
585
+ .hidden {
586
+ display: none;
587
+ }
588
+
589
+ @media(max-width: 950px) {
590
+ .layout {
591
+ grid-template-columns: 1fr;
592
+ }
593
+
594
+ .sidebar {
595
+ position: static;
596
+ }
597
+
598
+ .grid {
599
+ grid-template-columns: 1fr;
600
+ }
601
+ }
602
+ </style>
603
+ </head>
604
+
605
+ <body>
606
+ <div class="layout">
607
+ <aside class="sidebar">
608
+ <div class="brand">Admin Panel</div>
609
+ <div class="sub">GraphResearcher internal tools</div>
610
+
611
+ <label>Admin email</label>
612
+ <input id="adminEmail" value="2006yugb@gmail.com">
613
+
614
+ <button onclick="saveAdmin()">Use Admin Email</button>
615
+ <button onclick="loadStatus()" class="green">Dashboard</button>
616
+ <button onclick="loadUsers()">Users</button>
617
+ <button onclick="loadDocuments()">Documents</button>
618
+ <button onclick="loadConversations()">Conversations</button>
619
+ <button onclick="loadSystem()">System</button>
620
+ <button onclick="loadHealth()" class="dark">Deployment Health</button>
621
+ <button onclick="loadLLM()" class="dark">LLM Status</button>
622
+
623
+ <hr style="border-color: rgba(255,255,255,0.18); margin: 18px 0;">
624
+
625
+ <button onclick="window.open('/app','_blank')" class="dark">Open User App</button>
626
+ <button onclick="window.open('/docs','_blank')" class="dark">Open API Docs</button>
627
+ <button onclick="window.open('/graphrag-demo','_blank')" class="dark">Open GraphRAG Console</button>
628
+ <button onclick="logout()" class="red">Clear Admin Session</button>
629
+ </aside>
630
+
631
+ <main class="main">
632
+ <div class="top">
633
+ <div>
634
+ <h1>GraphResearcher Admin</h1>
635
+ <p style="color:#64748b;margin-top:-8px;">Hidden admin workspace for monitoring users, documents, system health, and developer tools.</p>
636
+ </div>
637
+ <span id="statusPill" class="status-pill">Not checked</span>
638
+ </div>
639
+
640
+ <div class="warning">
641
+ This page is hidden from the normal user app. The backend APIs still check admin role using the admin email.
642
+ Later Google OAuth will replace this temporary email-header login.
643
+ </div>
644
+
645
+ <div id="dashboardCards" class="grid">
646
+ <div class="card">
647
+ <h3>Users</h3>
648
+ <div id="usersMetric" class="metric">-</div>
649
+ </div>
650
+ <div class="card">
651
+ <h3>Documents</h3>
652
+ <div id="docsMetric" class="metric">-</div>
653
+ </div>
654
+ <div class="card">
655
+ <h3>Conversations</h3>
656
+ <div id="convMetric" class="metric">-</div>
657
+ </div>
658
+ <div class="card">
659
+ <h3>Status</h3>
660
+ <div id="systemMetric" class="metric">-</div>
661
+ </div>
662
+ </div>
663
+
664
+ <div class="card">
665
+ <h3 id="outputTitle">Output</h3>
666
+ <div id="tableOutput"></div>
667
+ <pre id="rawOutput">{}</pre>
668
+ </div>
669
+ </main>
670
+ </div>
671
+
672
+ <script>
673
+ function getAdminEmail() {
674
+ return localStorage.getItem("graphrag_admin_email") || document.getElementById("adminEmail").value.trim();
675
+ }
676
+
677
+ function saveAdmin() {
678
+ const email = document.getElementById("adminEmail").value.trim();
679
+ localStorage.setItem("graphrag_admin_email", email);
680
+ setStatus("Admin email saved");
681
+ loadStatus();
682
+ }
683
+
684
+ function logout() {
685
+ localStorage.removeItem("graphrag_admin_email");
686
+ setStatus("Admin cleared");
687
+ document.getElementById("rawOutput").textContent = "{}";
688
+ document.getElementById("tableOutput").innerHTML = "";
689
+ }
690
+
691
+ function headers() {
692
+ return {
693
+ "X-User-Email": getAdminEmail(),
694
+ "X-User-Name": "Admin"
695
+ };
696
+ }
697
+
698
+ function setStatus(text) {
699
+ document.getElementById("statusPill").textContent = text;
700
+ }
701
+
702
+ function showRaw(title, data) {
703
+ document.getElementById("outputTitle").textContent = title;
704
+ document.getElementById("rawOutput").textContent = JSON.stringify(data, null, 2);
705
+ }
706
+
707
+ async function fetchJson(url, useAdminHeaders = true) {
708
+ const response = await fetch(url, {
709
+ headers: useAdminHeaders ? headers() : {}
710
+ });
711
+
712
+ const data = await response.json();
713
+
714
+ if (!response.ok) {
715
+ throw new Error(JSON.stringify(data));
716
+ }
717
+
718
+ return data;
719
+ }
720
+
721
+ function renderTable(rows) {
722
+ const box = document.getElementById("tableOutput");
723
+
724
+ if (!rows || rows.length === 0) {
725
+ box.innerHTML = "<p>No rows.</p>";
726
+ return;
727
+ }
728
+
729
+ const columns = Object.keys(rows[0]).slice(0, 8);
730
+
731
+ let html = "<table><thead><tr>";
732
+
733
+ columns.forEach(col => {
734
+ html += `<th>${col}</th>`;
735
+ });
736
+
737
+ html += "</tr></thead><tbody>";
738
+
739
+ rows.forEach(row => {
740
+ html += "<tr>";
741
+ columns.forEach(col => {
742
+ html += `<td>${String(row[col] ?? "").slice(0, 80)}</td>`;
743
+ });
744
+ html += "</tr>";
745
+ });
746
+
747
+ html += "</tbody></table>";
748
+ box.innerHTML = html;
749
+ }
750
+
751
+ async function loadStatus() {
752
+ try {
753
+ setStatus("Loading...");
754
+ const data = await fetchJson("/admin/status");
755
+
756
+ showRaw("Admin Status", data);
757
+
758
+ const counts = data.database?.table_counts || {};
759
+ document.getElementById("usersMetric").textContent = counts.users ?? "-";
760
+ document.getElementById("docsMetric").textContent = counts.user_documents ?? "-";
761
+ document.getElementById("convMetric").textContent = counts.conversations ?? "-";
762
+ document.getElementById("systemMetric").textContent = data.status || "ok";
763
+
764
+ document.getElementById("tableOutput").innerHTML = "";
765
+ setStatus("Admin verified");
766
+ } catch (error) {
767
+ setStatus("Access denied");
768
+ showRaw("Error", { error: error.message });
769
+ }
770
+ }
771
+
772
+ async function loadUsers() {
773
+ try {
774
+ setStatus("Loading users...");
775
+ const data = await fetchJson("/admin/users");
776
+ showRaw("Users", data);
777
+ renderTable(data.users || []);
778
+ setStatus("Users loaded");
779
+ } catch (error) {
780
+ setStatus("Error");
781
+ showRaw("Error", { error: error.message });
782
+ }
783
+ }
784
+
785
+ async function loadDocuments() {
786
+ try {
787
+ setStatus("Loading documents...");
788
+ const data = await fetchJson("/admin/documents");
789
+ showRaw("Documents", data);
790
+ renderTable(data.documents || []);
791
+ setStatus("Documents loaded");
792
+ } catch (error) {
793
+ setStatus("Error");
794
+ showRaw("Error", { error: error.message });
795
+ }
796
+ }
797
+
798
+ async function loadConversations() {
799
+ try {
800
+ setStatus("Loading conversations...");
801
+ const data = await fetchJson("/admin/conversations");
802
+ showRaw("Conversations", data);
803
+ renderTable(data.conversations || []);
804
+ setStatus("Conversations loaded");
805
+ } catch (error) {
806
+ setStatus("Error");
807
+ showRaw("Error", { error: error.message });
808
+ }
809
+ }
810
+
811
+ async function loadSystem() {
812
+ try {
813
+ setStatus("Loading system...");
814
+ const data = await fetchJson("/admin/system");
815
+ showRaw("System", data);
816
+ document.getElementById("tableOutput").innerHTML = "";
817
+ setStatus("System loaded");
818
+ } catch (error) {
819
+ setStatus("Error");
820
+ showRaw("Error", { error: error.message });
821
+ }
822
+ }
823
+
824
+ async function loadHealth() {
825
+ try {
826
+ setStatus("Loading health...");
827
+ const data = await fetchJson("/deployment/health", false);
828
+ showRaw("Deployment Health", data);
829
+ document.getElementById("tableOutput").innerHTML = "";
830
+ setStatus("Health loaded");
831
+ } catch (error) {
832
+ setStatus("Error");
833
+ showRaw("Error", { error: error.message });
834
+ }
835
+ }
836
+
837
+ async function loadLLM() {
838
+ try {
839
+ setStatus("Loading LLM...");
840
+ const data = await fetchJson("/llm/status", false);
841
+ showRaw("LLM Status", data);
842
+ document.getElementById("tableOutput").innerHTML = "";
843
+ setStatus("LLM loaded");
844
+ } catch (error) {
845
+ setStatus("Error");
846
+ showRaw("Error", { error: error.message });
847
+ }
848
+ }
849
+
850
+ document.getElementById("adminEmail").value = getAdminEmail();
851
+ loadStatus();
852
+ </script>
853
+ </body>
854
+ </html>
855
+ """
856
+ ''', encoding="utf-8")
857
+
858
+
859
+ # =====================================================
860
+ # 5. Patch main.py
861
+ # =====================================================
862
+
863
+ main_path = Path("app/main.py")
864
+ main_text = main_path.read_text(encoding="utf-8-sig")
865
+ main_text = main_text.replace("\ufeff", "")
866
+
867
+ required_imports = '''
868
+ from fastapi import Request, Query
869
+ from fastapi.responses import HTMLResponse
870
+ from app.product.auth_service import get_current_user_from_request, require_admin_user, dev_login_user
871
+ from app.product.admin_service import get_admin_status, get_admin_users, get_admin_documents, get_admin_conversations, get_admin_system_summary
872
+ from app.product.admin_ui import get_admin_panel_html
873
+ '''
874
+
875
+ if "from app.product.admin_ui import get_admin_panel_html" not in main_text:
876
+ main_text = required_imports + "\n" + main_text
877
+
878
+ if '@app.get("/auth/me")' not in main_text:
879
+ main_text += '''
880
+
881
+ # Auth foundation endpoints
882
+
883
+ @app.get("/auth/me")
884
+ def auth_me(request: Request):
885
+ return get_current_user_from_request(request)
886
+
887
+
888
+ @app.get("/auth/dev-login")
889
+ def auth_dev_login(
890
+ email: str = Query(..., min_length=3),
891
+ name: str = Query(None)
892
+ ):
893
+ return dev_login_user(email=email, name=name)
894
+ '''
895
+
896
+ if '@app.get("/admin",' not in main_text:
897
+ main_text += '''
898
+
899
+ # Hidden admin panel UI
900
+
901
+ @app.get("/admin", response_class=HTMLResponse)
902
+ def admin_panel_page():
903
+ return get_admin_panel_html()
904
+ '''
905
+
906
+ if '@app.get("/admin/status")' not in main_text:
907
+ main_text += '''
908
+
909
+ # Admin API endpoints
910
+
911
+ @app.get("/admin/status")
912
+ def admin_status(request: Request):
913
+ current_admin = require_admin_user(request)
914
+ return get_admin_status(current_admin=current_admin)
915
+
916
+
917
+ @app.get("/admin/users")
918
+ def admin_users(
919
+ request: Request,
920
+ limit: int = Query(100, ge=1, le=500)
921
+ ):
922
+ require_admin_user(request)
923
+ return get_admin_users(limit=limit)
924
+
925
+
926
+ @app.get("/admin/documents")
927
+ def admin_documents(
928
+ request: Request,
929
+ limit: int = Query(100, ge=1, le=500)
930
+ ):
931
+ require_admin_user(request)
932
+ return get_admin_documents(limit=limit)
933
+
934
+
935
+ @app.get("/admin/conversations")
936
+ def admin_conversations(
937
+ request: Request,
938
+ limit: int = Query(100, ge=1, le=500)
939
+ ):
940
+ require_admin_user(request)
941
+ return get_admin_conversations(limit=limit)
942
+
943
+
944
+ @app.get("/admin/system")
945
+ def admin_system(request: Request):
946
+ require_admin_user(request)
947
+ return get_admin_system_summary()
948
+ '''
949
+
950
+ main_path.write_text(main_text, encoding="utf-8")
951
+
952
+ print("Phase 31 hidden admin panel patch complete.")