ltcai 4.0.0 → 4.1.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.
Files changed (195) hide show
  1. package/README.md +42 -33
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +106 -0
  4. package/docs/REALTIME_COLLABORATION.md +3 -3
  5. package/docs/V3_FRONTEND.md +9 -8
  6. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  7. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  8. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  9. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
  10. package/docs/kg-schema.md +6 -2
  11. package/docs/spec-vs-impl.md +10 -10
  12. package/frontend/index.html +24 -0
  13. package/frontend/openapi.json +14190 -0
  14. package/frontend/src/App.tsx +184 -0
  15. package/frontend/src/api/client.ts +317 -0
  16. package/frontend/src/api/openapi.ts +16637 -0
  17. package/frontend/src/components/primitives.tsx +204 -0
  18. package/frontend/src/components/ui/badge.tsx +27 -0
  19. package/frontend/src/components/ui/button.tsx +37 -0
  20. package/frontend/src/components/ui/card.tsx +22 -0
  21. package/frontend/src/components/ui/input.tsx +16 -0
  22. package/frontend/src/components/ui/textarea.tsx +16 -0
  23. package/frontend/src/lib/utils.ts +33 -0
  24. package/frontend/src/main.tsx +23 -0
  25. package/frontend/src/pages/Act.tsx +245 -0
  26. package/frontend/src/pages/Ask.tsx +200 -0
  27. package/frontend/src/pages/Brain.tsx +267 -0
  28. package/frontend/src/pages/Capture.tsx +158 -0
  29. package/frontend/src/pages/Library.tsx +187 -0
  30. package/frontend/src/pages/System.tsx +344 -0
  31. package/frontend/src/routes.ts +85 -0
  32. package/frontend/src/store/appStore.ts +54 -0
  33. package/frontend/src/styles.css +107 -0
  34. package/kg_schema.py +2 -603
  35. package/knowledge_graph.py +37 -4958
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +15 -16
  38. package/latticeai/api/agents.py +13 -6
  39. package/latticeai/api/auth.py +19 -11
  40. package/latticeai/api/invitations.py +100 -0
  41. package/latticeai/api/knowledge_graph.py +4 -11
  42. package/latticeai/api/plugins.py +3 -6
  43. package/latticeai/api/realtime.py +4 -7
  44. package/latticeai/api/setup.py +5 -4
  45. package/latticeai/api/static_routes.py +13 -16
  46. package/latticeai/api/ui_redirects.py +26 -0
  47. package/latticeai/api/workflow_designer.py +39 -6
  48. package/latticeai/api/workspace.py +24 -10
  49. package/latticeai/app_factory.py +88 -17
  50. package/latticeai/brain/_kg_common.py +1123 -0
  51. package/latticeai/brain/discovery.py +1455 -0
  52. package/latticeai/brain/documents.py +218 -0
  53. package/latticeai/brain/ingest.py +644 -0
  54. package/latticeai/brain/projection.py +561 -0
  55. package/latticeai/brain/provenance.py +401 -0
  56. package/latticeai/brain/retrieval.py +1316 -0
  57. package/latticeai/brain/schema.py +640 -0
  58. package/latticeai/brain/store.py +216 -0
  59. package/latticeai/brain/write_master.py +225 -0
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/multi_agent.py +1 -1
  63. package/latticeai/core/policy.py +54 -0
  64. package/latticeai/core/realtime.py +65 -44
  65. package/latticeai/core/sessions.py +31 -5
  66. package/latticeai/core/users.py +147 -0
  67. package/latticeai/core/workspace_os.py +420 -20
  68. package/latticeai/services/agent_runtime.py +242 -4
  69. package/latticeai/services/run_executor.py +328 -0
  70. package/latticeai/services/workspace_service.py +27 -19
  71. package/package.json +54 -27
  72. package/scripts/build_frontend_assets.mjs +38 -0
  73. package/scripts/bump_version.py +1 -1
  74. package/scripts/export_openapi.py +31 -0
  75. package/scripts/lint_frontend.mjs +86 -0
  76. package/scripts/run_python.mjs +47 -0
  77. package/src-tauri/Cargo.lock +4833 -0
  78. package/src-tauri/Cargo.toml +19 -0
  79. package/src-tauri/build.rs +3 -0
  80. package/src-tauri/capabilities/default.json +7 -0
  81. package/src-tauri/src/main.rs +78 -0
  82. package/src-tauri/tauri.conf.json +36 -0
  83. package/static/app/asset-manifest.json +32 -0
  84. package/static/app/assets/core-CwxXejkd.js +2 -0
  85. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  86. package/static/app/assets/index-CJRAzNnf.js +333 -0
  87. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  88. package/static/app/assets/index-CSwBBgf4.css +2 -0
  89. package/static/app/index.html +25 -0
  90. package/static/manifest.json +2 -2
  91. package/static/sw.js +4 -4
  92. package/scripts/build_v3_assets.mjs +0 -170
  93. package/scripts/lint_v3.mjs +0 -97
  94. package/static/account.html +0 -113
  95. package/static/activity.html +0 -73
  96. package/static/admin.html +0 -486
  97. package/static/agents.html +0 -139
  98. package/static/chat.html +0 -841
  99. package/static/css/reference/account.css +0 -439
  100. package/static/css/reference/admin.css +0 -610
  101. package/static/css/reference/base.css +0 -1661
  102. package/static/css/reference/chat.css +0 -4623
  103. package/static/css/reference/graph.css +0 -1016
  104. package/static/css/responsive.css +0 -861
  105. package/static/graph.html +0 -122
  106. package/static/platform.css +0 -104
  107. package/static/plugins.html +0 -136
  108. package/static/scripts/account.js +0 -238
  109. package/static/scripts/admin.js +0 -1614
  110. package/static/scripts/chat.js +0 -5081
  111. package/static/scripts/graph.js +0 -1804
  112. package/static/scripts/platform.js +0 -64
  113. package/static/scripts/ux.js +0 -167
  114. package/static/scripts/workspace.js +0 -948
  115. package/static/v3/asset-manifest.json +0 -56
  116. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  117. package/static/v3/css/lattice.base.css +0 -128
  118. package/static/v3/css/lattice.components.cde18231.css +0 -472
  119. package/static/v3/css/lattice.components.css +0 -472
  120. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  121. package/static/v3/css/lattice.shell.css +0 -452
  122. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  123. package/static/v3/css/lattice.tokens.css +0 -135
  124. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  125. package/static/v3/css/lattice.views.css +0 -360
  126. package/static/v3/index.html +0 -68
  127. package/static/v3/js/app.356e6452.js +0 -26
  128. package/static/v3/js/app.js +0 -26
  129. package/static/v3/js/core/api.7a308b89.js +0 -568
  130. package/static/v3/js/core/api.js +0 -568
  131. package/static/v3/js/core/components.f25b3b93.js +0 -230
  132. package/static/v3/js/core/components.js +0 -230
  133. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  134. package/static/v3/js/core/dom.js +0 -148
  135. package/static/v3/js/core/router.584570f2.js +0 -37
  136. package/static/v3/js/core/router.js +0 -37
  137. package/static/v3/js/core/routes.7222343d.js +0 -93
  138. package/static/v3/js/core/routes.js +0 -93
  139. package/static/v3/js/core/shell.a1657f20.js +0 -391
  140. package/static/v3/js/core/shell.js +0 -391
  141. package/static/v3/js/core/store.204a08b2.js +0 -113
  142. package/static/v3/js/core/store.js +0 -113
  143. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  144. package/static/v3/js/views/admin-audit.js +0 -185
  145. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  146. package/static/v3/js/views/admin-permissions.js +0 -177
  147. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  148. package/static/v3/js/views/admin-policies.js +0 -102
  149. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  150. package/static/v3/js/views/admin-private-vpc.js +0 -135
  151. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  152. package/static/v3/js/views/admin-security.js +0 -180
  153. package/static/v3/js/views/admin-users.03bac88c.js +0 -168
  154. package/static/v3/js/views/admin-users.js +0 -168
  155. package/static/v3/js/views/agents.014d0b74.js +0 -541
  156. package/static/v3/js/views/agents.js +0 -541
  157. package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
  158. package/static/v3/js/views/chat.js +0 -601
  159. package/static/v3/js/views/files.adad14c1.js +0 -365
  160. package/static/v3/js/views/files.js +0 -365
  161. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  162. package/static/v3/js/views/graph-canvas.js +0 -509
  163. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  164. package/static/v3/js/views/home.js +0 -200
  165. package/static/v3/js/views/hooks.37895880.js +0 -220
  166. package/static/v3/js/views/hooks.js +0 -220
  167. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  168. package/static/v3/js/views/hybrid-search.js +0 -194
  169. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
  170. package/static/v3/js/views/knowledge-graph.js +0 -509
  171. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  172. package/static/v3/js/views/marketplace.js +0 -141
  173. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  174. package/static/v3/js/views/mcp.js +0 -114
  175. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  176. package/static/v3/js/views/memory.js +0 -147
  177. package/static/v3/js/views/models.a1ffa147.js +0 -256
  178. package/static/v3/js/views/models.js +0 -256
  179. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  180. package/static/v3/js/views/my-computer.js +0 -463
  181. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  182. package/static/v3/js/views/pipeline.js +0 -157
  183. package/static/v3/js/views/planning.9ac3e313.js +0 -153
  184. package/static/v3/js/views/planning.js +0 -153
  185. package/static/v3/js/views/settings.8631fa5e.js +0 -318
  186. package/static/v3/js/views/settings.js +0 -318
  187. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  188. package/static/v3/js/views/skills.js +0 -109
  189. package/static/v3/js/views/tools.e4f11276.js +0 -108
  190. package/static/v3/js/views/tools.js +0 -108
  191. package/static/v3/js/views/workflows.26c57290.js +0 -128
  192. package/static/v3/js/views/workflows.js +0 -128
  193. package/static/workflows.html +0 -146
  194. package/static/workspace.css +0 -1121
  195. package/static/workspace.html +0 -357
@@ -1,1614 +0,0 @@
1
- /* Lattice AI - admin.html scripts */
2
-
3
- const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
4
-
5
- function apiFetch(path, options = {}) {
6
- const headers = { ...(options.headers || {}) };
7
- return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
8
- }
9
-
10
- function currentUserEmail() {
11
- return localStorage.getItem('ltcai_user_email') || '';
12
- }
13
-
14
- function currentUserNickname() {
15
- return localStorage.getItem('ltcai_user_nickname') || 'Guest';
16
- }
17
-
18
- function currentUserIsAdmin() {
19
- return localStorage.getItem('ltcai_is_admin') === 'true';
20
- }
21
-
22
- function restoreSessionFromQuery() {
23
- const raw = sessionStorage.getItem('ltcai_admin_handoff');
24
- if (!raw) return;
25
- sessionStorage.removeItem('ltcai_admin_handoff');
26
- let data;
27
- try { data = JSON.parse(raw); } catch { return; }
28
- const { email, nickname, is_admin } = data;
29
- if (!email) return;
30
- localStorage.setItem('ltcai_user_email', email);
31
- if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
32
- if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
33
- }
34
-
35
- function adminHeaders() {
36
- return {
37
- 'Content-Type': 'application/json',
38
- 'X-Admin-Email': currentUserEmail(),
39
- };
40
- }
41
-
42
- const A18N = {
43
- ko: {
44
- admin_sub: '관리자 대시보드',
45
- btn_back: '채팅으로',
46
- btn_refresh: '새로고침',
47
- btn_logout: '로그아웃',
48
- nav_dashboard: '대시보드',
49
- nav_users: '사용자 관리',
50
- nav_permissions: '권한 관리',
51
- nav_sso: 'SSO 관리',
52
- nav_enterprise: 'Enterprise',
53
- nav_security: '보안 모니터링',
54
- nav_audit: '감사 로그',
55
- nav_chat: '채팅으로',
56
- system_admin: '시스템 관리자',
57
- hero_title: '관리자 대시보드',
58
- hero_desc: '운영 현황, 세션, 모델, VPC 상태를 요약해서 보여줍니다.',
59
- current_session: '현재 세션',
60
- checking_session: '세션 확인 중...',
61
- card_total_users: '전체 사용자',
62
- card_messages: '활성 메시지',
63
- card_model: '현재 모델',
64
- card_vpc: 'VPC 상태',
65
- meta_need_admin: '관리자 권한 필요',
66
- meta_msg_unavailable: '최근 메시지 정보를 불러올 수 없음',
67
- chart_title: '메시지 활동 (최근 14일)',
68
- chart_desc: '사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.',
69
- label_user: '사용자',
70
- label_email: '이메일',
71
- label_name: '이름',
72
- label_nickname: '닉네임',
73
- label_perm: '권한',
74
- label_status: '상태',
75
- label_actions: '관리',
76
- label_none: '없음',
77
- vpc_desc: '네트워크 프로필과 운영 상태를 수정합니다.',
78
- vpc_notes_ph: '운영 메모',
79
- vpc_save: '저장',
80
- vpc_loading: '불러오는 중...',
81
- vpc_saving: '저장 중...',
82
- vpc_saved: '저장되었습니다.',
83
- vpc_save_fail: '저장 실패',
84
- vpc_default_profile: '기본 VPC 프로필을 사용 중입니다.',
85
- vpc_last_saved: '마지막 저장:',
86
- vpc_standby: '대기',
87
- vpc_connected: '연결됨',
88
- vpc_needs_setup: '설정 필요',
89
- session_desc: '현재 로그인한 계정과 관리자 API 상태를 확인합니다.',
90
- session_no_info: '세션 정보가 없습니다',
91
- session_help_ok: '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.',
92
- session_help_fail: '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.',
93
- users_title: '사용자 관리',
94
- users_desc: '등록된 사용자와 활성/비활성 상태를 관리합니다.',
95
- invite_title: '초대 링크',
96
- invite_desc: '새 사용자를 초대할 링크를 확인하고 복사합니다.',
97
- btn_copy: '복사',
98
- copied: '복사됨',
99
- invite_gate_active: '초대 게이트 활성화됨',
100
- invite_gate_inactive: '초대 게이트 비활성 - 링크 없이도 접근 가능합니다.',
101
- permissions_title: '권한 관리',
102
- permissions_desc: '사용자별 기본 모드, 고급 모드, 관리자 모드 권한을 확인합니다.',
103
- permission_default: '기본 모드',
104
- permission_advanced: '고급 모드',
105
- permission_admin: '관리자 모드',
106
- permission_allowed: '허용',
107
- permission_blocked: '차단',
108
- permission_granted: '부여됨',
109
- permission_not_granted: '없음',
110
- sso_title: 'SSO 관리',
111
- sso_desc: 'Okta 또는 Microsoft Entra ID OIDC 설정을 저장하고 로그인 플로우에 연결합니다.',
112
- sso_provider_template: '제공자 템플릿',
113
- sso_provider_name: '제공자 이름',
114
- sso_discovery_url: 'OIDC Discovery URL',
115
- sso_client_id: 'Client ID',
116
- sso_client_secret: 'Client Secret',
117
- sso_redirect_uri: 'Redirect URI',
118
- sso_scopes: 'Scopes',
119
- sso_secret_ph: '비워두면 기존 값을 유지합니다',
120
- sso_loading: 'SSO 설정을 불러오는 중...',
121
- sso_save: 'SSO 설정 저장',
122
- sso_test: 'SSO 로그인 테스트',
123
- sso_saved: 'SSO 설정이 저장되었습니다.',
124
- sso_ready: '연동 준비됨',
125
- sso_not_ready: '설정 필요',
126
- sso_secret_saved: '시크릿 저장됨',
127
- sso_secret_missing: '시크릿 없음',
128
- sso_okta_help: 'Okta Admin Console에서 OIDC Web App을 만들고 Sign-in redirect URI에 아래 Redirect URI를 등록하세요.',
129
- sso_entra_help: 'Microsoft Entra ID 앱 등록에서 Web redirect URI를 등록하고 Client secret을 생성하세요.',
130
- sso_custom_help: '표준 OIDC discovery endpoint, client ID, client secret, redirect URI를 입력하세요.',
131
- sensitivity_title: '보안 모니터링',
132
- sensitivity_desc: '민감정보, 위험 필드, 준수 필드를 집중적으로 확인합니다.',
133
- sensitivity_risk: '위험',
134
- sensitivity_compliant: '준수',
135
- sensitivity_risk_rate: '위험률',
136
- sensitivity_high: '높음',
137
- risk_fields: '위험 필드',
138
- compliance_fields: '준수 필드',
139
- no_risk_fields: '감지된 위험 필드가 없습니다.',
140
- no_compliance_fields: '준수 항목이 없습니다.',
141
- security_export_toggle: '보안 모니터링 로그 추출',
142
- audit_export_toggle: '감사 로그 추출',
143
- export_txt: 'TXT 추출',
144
- export_excel: 'Excel 추출',
145
- export_csv: 'CSV 추출',
146
- export_no_data: '추출할 데이터가 없습니다.',
147
- audit_title: '감사 로그',
148
- audit_desc: 'AI 사용량, 업로드, 민감정보 감지, 삭제/정리 이벤트를 보존합니다.',
149
- audit_user_risk: '사용자 사용량 및 위험도',
150
- audit_trail: '감사 이벤트',
151
- audit_no_data: '감사 데이터가 아직 없습니다.',
152
- audit_no_events: '최근 감사 이벤트가 없습니다.',
153
- loading: '불러오는 중...',
154
- no_users: '사용자 데이터가 없습니다.',
155
- status_active: '활성',
156
- status_inactive: '비활성',
157
- role_admin: '관리자',
158
- role_user: '사용자',
159
- btn_grant_admin: '관리자 지정',
160
- btn_revoke_admin: '권한 해제',
161
- btn_activate: '활성화',
162
- btn_deactivate: '비활성화',
163
- btn_delete: '삭제',
164
- confirm_delete: '사용자를 삭제할까요?',
165
- err_no_admin: '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.',
166
- err_partial: '일부 섹션을 불러오지 못했습니다:',
167
- err_network: '네트워크 연결을 확인해 주세요.',
168
- err_load: '대시보드를 불러오지 못했습니다.',
169
- section_summary: '요약',
170
- section_users: '사용자 목록',
171
- section_sensitivity: '보안 모니터링',
172
- section_audit: '감사 로그',
173
- section_sso: 'SSO 관리',
174
- enterprise_title: 'Enterprise 관리자',
175
- enterprise_desc: '관리자 정책, 감사 추출, SIEM 추출, 조직 설정, 기능 상태를 확인합니다.',
176
- enterprise_policies: '관리자 정책',
177
- enterprise_policies_desc: 'Community 유효 정책과 Enterprise 정책 팩 상태입니다.',
178
- enterprise_org: '조직 설정',
179
- enterprise_org_desc: '워크스페이스 거버넌스와 조직 기능 상태입니다.',
180
- enterprise_audit_export: '감사 추출',
181
- enterprise_audit_export_desc: 'Community에서는 로컬 추출이 가능하며 보존 정책은 Enterprise 확장 지점입니다.',
182
- enterprise_siem: 'SIEM 추출',
183
- enterprise_siem_desc: 'Community에서 외부 이벤트를 전송하지 않고 SIEM envelope를 미리 봅니다.',
184
- },
185
- en: {
186
- admin_sub: 'Admin Dashboard',
187
- btn_back: 'Chat',
188
- btn_refresh: 'Refresh',
189
- btn_logout: 'Logout',
190
- nav_dashboard: 'Dashboard',
191
- nav_users: 'User Management',
192
- nav_permissions: 'Permission Management',
193
- nav_sso: 'SSO Management',
194
- nav_enterprise: 'Enterprise',
195
- nav_security: 'Security Monitoring',
196
- nav_audit: 'Audit Logs',
197
- nav_chat: 'Back to Chat',
198
- system_admin: 'System Administrator',
199
- hero_title: 'Admin Dashboard',
200
- hero_desc: 'Summarize operations, session, model, and VPC status.',
201
- current_session: 'Current Session',
202
- checking_session: 'Checking session...',
203
- card_total_users: 'Total Users',
204
- card_messages: 'Active Messages',
205
- card_model: 'Current Model',
206
- card_vpc: 'VPC Status',
207
- meta_need_admin: 'Admin permission required',
208
- meta_msg_unavailable: 'Could not load recent message info',
209
- chart_title: 'Message Activity (Last 14 Days)',
210
- chart_desc: 'User messages and AI responses by day.',
211
- label_user: 'User',
212
- label_email: 'Email',
213
- label_name: 'Name',
214
- label_nickname: 'Nickname',
215
- label_perm: 'Role',
216
- label_status: 'Status',
217
- label_actions: 'Actions',
218
- label_none: 'None',
219
- vpc_desc: 'Edit the network profile and operating state.',
220
- vpc_notes_ph: 'Operations notes',
221
- vpc_save: 'Save',
222
- vpc_loading: 'Loading...',
223
- vpc_saving: 'Saving...',
224
- vpc_saved: 'Saved.',
225
- vpc_save_fail: 'Save failed',
226
- vpc_default_profile: 'Using the default VPC profile.',
227
- vpc_last_saved: 'Last saved:',
228
- vpc_standby: 'Standby',
229
- vpc_connected: 'Connected',
230
- vpc_needs_setup: 'Setup required',
231
- session_desc: 'Check the current login account and admin API status.',
232
- session_no_info: 'No session info',
233
- session_help_ok: 'Email header is set, so admin API calls are available.',
234
- session_help_fail: 'Log in from the chat screen first, then open this screen.',
235
- users_title: 'User Management',
236
- users_desc: 'Manage registered users and active/inactive status.',
237
- invite_title: 'Invite Link',
238
- invite_desc: 'View and copy the link for inviting new users.',
239
- btn_copy: 'Copy',
240
- copied: 'Copied',
241
- invite_gate_active: 'Invite gate active',
242
- invite_gate_inactive: 'Invite gate disabled - users can access without a link.',
243
- permissions_title: 'Permission Management',
244
- permissions_desc: 'Review Default Mode, Advanced Mode, and Admin Mode permissions by user.',
245
- permission_default: 'Default Mode',
246
- permission_advanced: 'Advanced Mode',
247
- permission_admin: 'Admin Mode',
248
- permission_allowed: 'Allowed',
249
- permission_blocked: 'Blocked',
250
- permission_granted: 'Granted',
251
- permission_not_granted: 'None',
252
- sso_title: 'SSO Management',
253
- sso_desc: 'Save Okta or Microsoft Entra ID OIDC settings and connect them to the login flow.',
254
- sso_provider_template: 'Provider Template',
255
- sso_provider_name: 'Provider Name',
256
- sso_discovery_url: 'OIDC Discovery URL',
257
- sso_client_id: 'Client ID',
258
- sso_client_secret: 'Client Secret',
259
- sso_redirect_uri: 'Redirect URI',
260
- sso_scopes: 'Scopes',
261
- sso_secret_ph: 'Leave blank to keep the existing value',
262
- sso_loading: 'Loading SSO settings...',
263
- sso_save: 'Save SSO Settings',
264
- sso_test: 'Test SSO Login',
265
- sso_saved: 'SSO settings saved.',
266
- sso_ready: 'Ready',
267
- sso_not_ready: 'Needs setup',
268
- sso_secret_saved: 'Secret saved',
269
- sso_secret_missing: 'No secret',
270
- sso_okta_help: 'Create an OIDC Web App in Okta Admin Console and add the Redirect URI below as the sign-in redirect URI.',
271
- sso_entra_help: 'Register a web app in Microsoft Entra ID, add the Redirect URI, and create a client secret.',
272
- sso_custom_help: 'Enter a standard OIDC discovery endpoint, client ID, client secret, and redirect URI.',
273
- sensitivity_title: 'Security Monitoring',
274
- sensitivity_desc: 'Focus on sensitive data, risk fields, and compliance fields.',
275
- sensitivity_risk: 'Risk',
276
- sensitivity_compliant: 'Compliant',
277
- sensitivity_risk_rate: 'Risk rate',
278
- sensitivity_high: 'High',
279
- risk_fields: 'Risk Fields',
280
- compliance_fields: 'Compliance Fields',
281
- no_risk_fields: 'No risk fields detected.',
282
- no_compliance_fields: 'No compliance items.',
283
- security_export_toggle: 'Export Security Logs',
284
- audit_export_toggle: 'Export Audit Logs',
285
- export_txt: 'Export TXT',
286
- export_excel: 'Export Excel',
287
- export_csv: 'Export CSV',
288
- export_no_data: 'No data to export.',
289
- audit_title: 'Audit Logs',
290
- audit_desc: 'Preserve AI usage, uploads, sensitive detections, and delete/cleanup events.',
291
- audit_user_risk: 'User Usage & Risk',
292
- audit_trail: 'Audit Trail',
293
- audit_no_data: 'No audit data yet.',
294
- audit_no_events: 'No recent audit events.',
295
- loading: 'Loading...',
296
- no_users: 'No user data.',
297
- status_active: 'Active',
298
- status_inactive: 'Inactive',
299
- role_admin: 'Admin',
300
- role_user: 'User',
301
- btn_grant_admin: 'Make Admin',
302
- btn_revoke_admin: 'Remove Admin',
303
- btn_activate: 'Activate',
304
- btn_deactivate: 'Deactivate',
305
- btn_delete: 'Delete',
306
- confirm_delete: 'Delete this user?',
307
- err_no_admin: 'No admin permission. Log in as an admin from the chat screen.',
308
- err_partial: 'Failed to load some sections:',
309
- err_network: 'Please check your network connection.',
310
- err_load: 'Could not load dashboard.',
311
- section_summary: 'Summary',
312
- section_users: 'User list',
313
- section_sensitivity: 'Security monitoring',
314
- section_audit: 'Audit logs',
315
- section_sso: 'SSO management',
316
- enterprise_title: 'Enterprise Admin',
317
- enterprise_desc: 'Review admin policies, audit export, SIEM export, organization settings, and capability status.',
318
- enterprise_policies: 'Admin Policies',
319
- enterprise_policies_desc: 'Effective Community policy and Enterprise policy-pack status.',
320
- enterprise_org: 'Organization Settings',
321
- enterprise_org_desc: 'Workspace governance and organization capability status.',
322
- enterprise_audit_export: 'Audit Export',
323
- enterprise_audit_export_desc: 'Community local export is available; retention is an Enterprise extension point.',
324
- enterprise_siem: 'SIEM Export',
325
- enterprise_siem_desc: 'Preview the SIEM envelope without streaming external events in Community.',
326
- }
327
- };
328
-
329
- let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
330
- let activityChartInstance = null;
331
- let latestUsers = [];
332
- let latestSso = null;
333
- let latestSensitivity = null;
334
- let latestAudit = null;
335
- let latestEnterprise = null;
336
-
337
- function t(key) {
338
- return (A18N[currentLang] || A18N.ko)[key] || key;
339
- }
340
-
341
- function applyI18n() {
342
- document.documentElement.lang = currentLang;
343
- document.querySelectorAll('[data-i18n]').forEach(el => {
344
- if (el.id === 'session-help') return;
345
- const val = t(el.dataset.i18n);
346
- if (val) el.textContent = val;
347
- });
348
- document.querySelectorAll('[data-i18n-ph]').forEach(el => {
349
- const val = t(el.dataset.i18nPh);
350
- if (val) el.placeholder = val;
351
- });
352
- ['ko', 'en'].forEach(lang => {
353
- const el = document.getElementById(`admin-lang-${lang}`);
354
- if (el) el.classList.toggle('active', lang === currentLang);
355
- });
356
- const langBtn = document.getElementById('admin-lang-btn');
357
- if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
358
- }
359
-
360
- function toggleLangMenu(pickerId) {
361
- const menu = document.getElementById(`${pickerId}-menu`);
362
- if (!menu) return;
363
- const isOpen = menu.classList.contains('open');
364
- document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
365
- if (!isOpen) menu.classList.add('open');
366
- }
367
-
368
- function setLang(lang) {
369
- currentLang = lang;
370
- localStorage.setItem('ltcai_lang', lang);
371
- document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
372
- applyI18n();
373
- renderUsers(latestUsers);
374
- renderPermissions(latestUsers);
375
- renderSso(latestSso);
376
- renderSensitivity(latestSensitivity);
377
- renderAudit(latestAudit);
378
- renderEnterpriseAdmin(latestEnterprise);
379
- loadDashboard();
380
- }
381
-
382
- window.toggleLangMenu = toggleLangMenu;
383
- window.setLang = setLang;
384
-
385
- document.addEventListener('click', e => {
386
- if (!e.target.closest('.lang-picker')) {
387
- document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
388
- }
389
- });
390
-
391
- function switchAdminView(view) {
392
- const target = view || 'dashboard';
393
- document.querySelectorAll('[data-admin-view]').forEach(section => {
394
- section.classList.toggle('active', section.dataset.adminView === target);
395
- });
396
- document.querySelectorAll('[data-admin-nav]').forEach(link => {
397
- link.classList.toggle('active', link.dataset.adminNav === target);
398
- });
399
- if (location.hash.slice(1) !== target) history.replaceState(null, '', `#${target}`);
400
- }
401
-
402
- function initAdminNav() {
403
- document.querySelectorAll('[data-admin-nav]').forEach(link => {
404
- link.addEventListener('click', event => {
405
- event.preventDefault();
406
- switchAdminView(link.dataset.adminNav);
407
- });
408
- });
409
- const initial = location.hash.slice(1) || 'dashboard';
410
- switchAdminView(document.getElementById(`admin-view-${initial}`) ? initial : 'dashboard');
411
- }
412
-
413
- function esc(value) {
414
- return String(value ?? '')
415
- .replace(/&/g, '&')
416
- .replace(/</g, '&lt;')
417
- .replace(/>/g, '&gt;')
418
- .replace(/"/g, '&quot;')
419
- .replace(/'/g, '&#039;');
420
- }
421
-
422
- function compactModelName(modelId, maxLength = 28) {
423
- if (!modelId) return 'None';
424
- const clean = String(modelId).replaceAll('mlx-community/', '');
425
- if (clean.length <= maxLength) return clean;
426
- const head = Math.max(8, maxLength - 10);
427
- return `${clean.slice(0, head)}...${clean.slice(-6)}`;
428
- }
429
-
430
- function sumStatValue(value) {
431
- if (value === null || value === undefined) return 0;
432
- if (typeof value === 'number') return value;
433
- if (typeof value === 'string') {
434
- const parsed = Number(value);
435
- return Number.isFinite(parsed) ? parsed : 0;
436
- }
437
- if (Array.isArray(value)) return value.reduce((total, item) => total + sumStatValue(item), 0);
438
- if (typeof value === 'object') return Object.values(value).reduce((total, item) => total + sumStatValue(item), 0);
439
- return 0;
440
- }
441
-
442
- function formatNumber(value) {
443
- const num = Number(value || 0);
444
- return Number.isFinite(num) ? num.toLocaleString(currentLang === 'ko' ? 'ko-KR' : 'en-US') : '0';
445
- }
446
-
447
- function roleLabel(role) {
448
- return role === 'admin' ? t('role_admin') : t('role_user');
449
- }
450
-
451
- function statusLabel(user) {
452
- return user.disabled ? t('status_inactive') : t('status_active');
453
- }
454
-
455
- function permissionTag(text, tone = 'low') {
456
- return `<span class="tag ${tone}">${esc(text)}</span>`;
457
- }
458
-
459
- function vpcHealthText(config) {
460
- if (!config) return t('vpc_standby');
461
- if (config.vpn_status === 'connected' || config.peering_status === 'active') return t('vpc_connected');
462
- if (config.vpn_status === 'standby') return t('vpc_standby');
463
- return config.vpn_status || config.peering_status || t('vpc_needs_setup');
464
- }
465
-
466
- function formatTime(value) {
467
- if (!value) return '-';
468
- const date = new Date(value);
469
- if (Number.isNaN(date.getTime())) return value;
470
- return date.toLocaleString(currentLang === 'ko' ? 'ko-KR' : 'en-US');
471
- }
472
-
473
- function renderActivityChart(daily = []) {
474
- if (!window.Chart) return;
475
- const labels = daily.map(d => d.date);
476
- const userData = daily.map(d => d.user);
477
- const aiData = daily.map(d => d.assistant);
478
- const canvas = document.getElementById('activity-chart');
479
- if (!canvas) return;
480
- const ctx = canvas.getContext('2d');
481
- if (activityChartInstance) activityChartInstance.destroy();
482
- activityChartInstance = new Chart(ctx, {
483
- type: 'bar',
484
- data: {
485
- labels,
486
- datasets: [
487
- { label: t('label_user'), data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
488
- { label: 'AI', data: aiData, backgroundColor: 'rgba(34,196,160,0.55)', borderRadius: 4 }
489
- ]
490
- },
491
- options: {
492
- responsive: true,
493
- plugins: { legend: { labels: { color: '#4a4668', font: { size: 12 } } } },
494
- scales: {
495
- x: { ticks: { color: '#7a74a0' }, grid: { color: 'rgba(111,66,232,0.08)' } },
496
- y: { ticks: { color: '#7a74a0', stepSize: 1 }, grid: { color: 'rgba(111,66,232,0.08)' }, beginAtZero: true }
497
- }
498
- }
499
- });
500
- }
501
-
502
- async function copyInviteLink() {
503
- const input = document.getElementById('invite-link-input');
504
- const btn = document.getElementById('copy-invite-btn');
505
- try {
506
- await navigator.clipboard.writeText(input.value);
507
- btn.querySelector('span').textContent = t('copied');
508
- setTimeout(() => btn.querySelector('span').textContent = t('btn_copy'), 1800);
509
- } catch {
510
- input.select();
511
- }
512
- }
513
-
514
- function setSessionInfo() {
515
- const email = currentUserEmail();
516
- const nick = currentUserNickname();
517
- const isAdmin = currentUserIsAdmin();
518
- document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : t('session_no_info');
519
- const tags = [
520
- [t('label_user'), nick, 'low'],
521
- [t('label_email'), email || t('label_none'), 'medium'],
522
- [t('label_perm'), isAdmin ? t('role_admin') : t('role_user'), isAdmin ? 'low' : 'medium']
523
- ];
524
- document.getElementById('session-tags').innerHTML = tags.map(([label, value, tone]) => `
525
- <span class="tag ${tone}"><span>${esc(label)}</span> ${esc(value)}</span>
526
- `).join('');
527
- document.getElementById('admin-pill').innerHTML = isAdmin
528
- ? '<i class="ti ti-shield-check"></i> Admin'
529
- : '<i class="ti ti-lock"></i> Read only';
530
- document.getElementById('session-help').textContent = email
531
- ? t('session_help_ok')
532
- : t('session_help_fail');
533
- }
534
-
535
- function fillVpcForm(config) {
536
- if (!config) return;
537
- document.getElementById('vpc-provider').value = config.provider || '';
538
- document.getElementById('vpc-region').value = config.region || '';
539
- document.getElementById('vpc-cidr').value = config.cidr_block || '';
540
- document.getElementById('vpc-endpoint').value = config.endpoint || '';
541
- document.getElementById('vpc-vpn').value = config.vpn_status || '';
542
- document.getElementById('vpc-peering').value = config.peering_status || '';
543
- document.getElementById('vpc-subnets').value = (config.private_subnets || []).join(', ');
544
- document.getElementById('vpc-notes').value = config.notes || '';
545
- document.getElementById('vpc-save-status').textContent = config.updated_at
546
- ? `${t('vpc_last_saved')} ${formatTime(config.updated_at)}`
547
- : t('vpc_default_profile');
548
- }
549
-
550
- function renderSummary(health, summary, vpc) {
551
- document.getElementById('total-users').textContent = summary ? summary.total_users : '-';
552
- document.getElementById('total-users-meta').textContent = summary
553
- ? `${summary.active_users} ${t('status_active')} - ${summary.admin_users} ${t('role_admin')}`
554
- : t('meta_need_admin');
555
- document.getElementById('total-messages').textContent = summary ? formatNumber(summary.total_messages) : '-';
556
- document.getElementById('total-messages-meta').textContent = summary
557
- ? `user ${summary.user_messages} - assistant ${summary.assistant_messages}`
558
- : t('meta_msg_unavailable');
559
- const modelValue = compactModelName(health?.current_model, 22);
560
- document.getElementById('current-model').textContent = modelValue;
561
- document.getElementById('current-model').title = health?.current_model || modelValue;
562
- document.getElementById('current-model-meta').textContent = `${health?.loaded_models?.length || 0} loaded - ${health?.device || 'local runtime'}`;
563
- document.getElementById('vpc-status').textContent = vpc?.provider || '-';
564
- document.getElementById('vpc-status').title = [vpc?.provider, vpc?.region].filter(Boolean).join(' ');
565
- document.getElementById('vpc-status-meta').textContent = `${vpc?.region || '-'} - ${vpc?.cidr_block || '-'} - ${vpcHealthText(vpc)}`;
566
- }
567
-
568
- function renderUsers(users) {
569
- latestUsers = Array.isArray(users) ? users : [];
570
- const wrap = document.getElementById('user-table-wrap');
571
- if (!latestUsers.length) {
572
- wrap.innerHTML = `<div class="preview" style="padding:14px">${t('no_users')}</div>`;
573
- return;
574
- }
575
- wrap.innerHTML = `
576
- <table>
577
- <thead>
578
- <tr>
579
- <th>${t('label_email')}</th>
580
- <th>${t('label_name')}</th>
581
- <th>${t('label_nickname')}</th>
582
- <th>${t('label_perm')}</th>
583
- <th>${t('label_status')}</th>
584
- <th>${t('label_actions')}</th>
585
- </tr>
586
- </thead>
587
- <tbody>
588
- ${latestUsers.map(user => `
589
- <tr>
590
- <td data-label="${t('label_email')}">${esc(user.email)}</td>
591
- <td data-label="${t('label_name')}">${esc(user.name || '-')}</td>
592
- <td data-label="${t('label_nickname')}">${esc(user.nickname || '-')}</td>
593
- <td data-label="${t('label_perm')}"><span class="role">${esc(roleLabel(user.role))}</span></td>
594
- <td data-label="${t('label_status')}">${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
595
- <td data-label="${t('label_actions')}">
596
- <div class="actions">
597
- <button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
598
- ${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
599
- </button>
600
- <button class="table-btn" data-action="disable" data-email="${esc(user.email)}" data-disabled="${user.disabled ? 'false' : 'true'}">
601
- ${user.disabled ? t('btn_activate') : t('btn_deactivate')}
602
- </button>
603
- <button class="table-btn danger" data-action="delete" data-email="${esc(user.email)}">${t('btn_delete')}</button>
604
- </div>
605
- </td>
606
- </tr>
607
- `).join('')}
608
- </tbody>
609
- </table>
610
- `;
611
- }
612
-
613
- function renderPermissions(users) {
614
- latestUsers = Array.isArray(users) ? users : latestUsers;
615
- const wrap = document.getElementById('permission-table-wrap');
616
- if (!latestUsers.length) {
617
- wrap.innerHTML = `<div class="preview" style="padding:14px">${t('no_users')}</div>`;
618
- return;
619
- }
620
- wrap.innerHTML = `
621
- <table>
622
- <thead>
623
- <tr>
624
- <th>${t('label_user')}</th>
625
- <th>${t('label_status')}</th>
626
- <th>${t('permission_default')}</th>
627
- <th>${t('permission_advanced')}</th>
628
- <th>${t('permission_admin')}</th>
629
- <th>${t('label_actions')}</th>
630
- </tr>
631
- </thead>
632
- <tbody>
633
- ${latestUsers.map(user => {
634
- const active = !user.disabled;
635
- const isAdmin = user.role === 'admin';
636
- return `
637
- <tr>
638
- <td data-label="${t('label_user')}">
639
- <strong>${esc(user.nickname || user.name || user.email)}</strong>
640
- <div class="preview">${esc(user.email)}</div>
641
- </td>
642
- <td data-label="${t('label_status')}">${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
643
- <td data-label="${t('permission_default')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
644
- <td data-label="${t('permission_advanced')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
645
- <td data-label="${t('permission_admin')}">${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
646
- <td data-label="${t('label_actions')}">
647
- <div class="actions">
648
- <button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${isAdmin ? 'user' : 'admin'}">
649
- ${isAdmin ? t('btn_revoke_admin') : t('btn_grant_admin')}
650
- </button>
651
- <button class="table-btn" data-action="disable" data-email="${esc(user.email)}" data-disabled="${user.disabled ? 'false' : 'true'}">
652
- ${user.disabled ? t('btn_activate') : t('btn_deactivate')}
653
- </button>
654
- </div>
655
- </td>
656
- </tr>
657
- `;
658
- }).join('')}
659
- </tbody>
660
- </table>
661
- `;
662
- }
663
-
664
- function renderSensitivity(report) {
665
- latestSensitivity = report || null;
666
- const summary = report?.summary || {};
667
- const severity = summary.severity_counts || {};
668
- const fieldCounts = summary.field_counts || {};
669
- const userCounts = summary.user_counts || {};
670
- const tags = [
671
- ['high', `${t('sensitivity_risk')} ${summary.risky_messages || 0}`],
672
- ['low', `${t('sensitivity_compliant')} ${summary.compliant_messages || 0}`],
673
- ['medium', `${t('sensitivity_risk_rate')} ${summary.risk_rate || 0}%`],
674
- ['high', `${t('sensitivity_high')} ${severity.high || 0}`]
675
- ];
676
- const fieldTags = Object.entries(fieldCounts).map(([label, count]) => ['medium', `${label} ${count}`]);
677
- const userTags = Object.entries(userCounts).map(([label, count]) => ['high', `${label} ${count}`]);
678
- document.getElementById('sensitivity-summary').innerHTML = [...tags, ...fieldTags, ...userTags]
679
- .map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
680
-
681
- const riskList = report?.risk_fields || [];
682
- const complianceList = report?.compliance_fields || [];
683
- document.getElementById('risk-fields').innerHTML = riskList.length
684
- ? riskList.slice().reverse().map(item => sensitivityItemHtml(item, true)).join('')
685
- : `<div class="preview">${t('no_risk_fields')}</div>`;
686
- document.getElementById('compliance-fields').innerHTML = complianceList.length
687
- ? complianceList.slice().reverse().map(item => sensitivityItemHtml(item, false)).join('')
688
- : `<div class="preview">${t('no_compliance_fields')}</div>`;
689
- }
690
-
691
- function sensitivityItemHtml(item, risky) {
692
- const labels = risky ? item.labels : item.compliance_fields;
693
- return `
694
- <div class="item">
695
- <div class="item-meta">
696
- <span class="tag">${esc(item.user_nickname || 'Unknown')}</span>
697
- <span class="tag">${esc(item.user_email || 'unknown')}</span>
698
- <span class="tag ${item.sensitivity || 'low'}">${esc(item.sensitivity || 'none')}</span>
699
- ${(labels || []).map(label => `<span class="tag ${risky ? 'medium' : 'low'}">${esc(label)}</span>`).join('')}
700
- </div>
701
- <div class="preview">${esc(item.preview || '')}</div>
702
- </div>
703
- `;
704
- }
705
-
706
- function auditEventLabel(event) {
707
- const labels = {
708
- chat_message: event?.role === 'assistant' ? 'AI response' : 'User message',
709
- document_upload: 'Document upload',
710
- clear_command: 'Chat clear',
711
- conversation_delete: 'Conversation delete',
712
- history_delete: 'History delete',
713
- user_delete: 'User delete',
714
- user_update: 'User update',
715
- sso_config_update: 'SSO config update',
716
- };
717
- return labels[event?.event_type] || event?.event_type || '-';
718
- }
719
-
720
- function auditTarget(event) {
721
- if (!event) return '-';
722
- if (event.filename) return event.filename;
723
- if (event.target_email) return `target: ${event.target_email}`;
724
- if (event.provider_name || event.discovery_url) return [event.provider_name, event.discovery_url].filter(Boolean).join(' - ');
725
- if (event.command) return `${event.command} - ${event.scope || '-'} - removed ${event.removed || 0}`;
726
- if (event.event_type === 'history_delete') return `history - removed ${event.removed || 0} - kept ${event.kept || 0}`;
727
- if (event.conversation_id) return `conversation ${String(event.conversation_id).slice(0, 18)}`;
728
- return event.content_preview || '-';
729
- }
730
-
731
- function renderAudit(audit) {
732
- latestAudit = audit || null;
733
- const summary = audit?.summary || {};
734
- const graph = audit?.graph || {};
735
- const graphNodes = sumStatValue(graph.nodes);
736
- const graphEdges = sumStatValue(graph.edges);
737
- const metrics = [
738
- ['Total Events', summary.total_events || 0, `${summary.chat_events || 0} chat events`],
739
- ['AI Usage', `${summary.user_messages || 0}/${summary.assistant_messages || 0}`, 'user / assistant'],
740
- ['Uploads', summary.document_uploads || 0, `${formatNumber(graphNodes)} graph nodes`],
741
- ['Clear Events', summary.clear_events || 0, 'screen cleanup only'],
742
- ['Sensitive', summary.sensitive_events || 0, `${summary.high_sensitive_events || 0} high risk`],
743
- ];
744
- document.getElementById('audit-metrics').innerHTML = metrics.map(([label, value, meta]) => `
745
- <div class="audit-metric">
746
- <div class="label">${esc(label)}</div>
747
- <div class="value">${esc(value)}</div>
748
- <div class="meta">${esc(meta)}</div>
749
- </div>
750
- `).join('');
751
-
752
- const tags = [
753
- ['low', `Graph nodes ${formatNumber(graphNodes)}`],
754
- ['low', `Edges ${formatNumber(graphEdges)}`],
755
- ['medium', `Deletes ${summary.delete_events || 0}`],
756
- [summary.high_sensitive_events ? 'high' : 'low', `High risk ${summary.high_sensitive_events || 0}`]
757
- ];
758
- document.getElementById('audit-summary-tags').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
759
-
760
- const users = audit?.per_user || [];
761
- document.getElementById('audit-user-table').innerHTML = users.length ? `
762
- <table>
763
- <thead>
764
- <tr>
765
- <th>${t('label_user')}</th>
766
- <th>AI Use</th>
767
- <th>Uploads</th>
768
- <th>Sensitive</th>
769
- <th>Clear/Delete</th>
770
- <th>Last Active</th>
771
- </tr>
772
- </thead>
773
- <tbody>
774
- ${users.map(user => `
775
- <tr>
776
- <td data-label="${t('label_user')}"><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
777
- <td data-label="AI Use">${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
778
- <td data-label="Uploads">${esc(user.document_uploads || 0)}</td>
779
- <td data-label="Sensitive">${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
780
- <td data-label="Clear/Delete">${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
781
- <td data-label="Last Active">${esc(formatTime(user.last_activity_at))}</td>
782
- </tr>
783
- `).join('')}
784
- </tbody>
785
- </table>
786
- ` : `<div class="preview" style="padding:14px">${t('audit_no_data')}</div>`;
787
-
788
- const events = audit?.recent_events || [];
789
- document.getElementById('audit-event-table').innerHTML = events.length ? `
790
- <table>
791
- <thead>
792
- <tr>
793
- <th>Time</th>
794
- <th>Event</th>
795
- <th>${t('label_user')}</th>
796
- <th>Target/Data</th>
797
- <th>Risk</th>
798
- </tr>
799
- </thead>
800
- <tbody>
801
- ${events.map(event => `
802
- <tr>
803
- <td data-label="Time">${esc(formatTime(event.timestamp))}</td>
804
- <td data-label="Event">${esc(auditEventLabel(event))}</td>
805
- <td data-label="${t('label_user')}">${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
806
- <td data-label="Target/Data">${esc(auditTarget(event))}</td>
807
- <td data-label="Risk">${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
808
- </tr>
809
- `).join('')}
810
- </tbody>
811
- </table>
812
- ` : `<div class="preview" style="padding:14px">${t('audit_no_events')}</div>`;
813
- }
814
-
815
- function enterpriseStatusTag(label, enabled) {
816
- return `<span class="tag ${enabled ? 'low' : 'medium'}">${esc(label)}: ${enabled ? 'enabled' : 'disabled'}</span>`;
817
- }
818
-
819
- function renderKeyValues(targetId, rows) {
820
- const target = document.getElementById(targetId);
821
- if (!target) return;
822
- target.innerHTML = `
823
- <div class="enterprise-kv">
824
- ${rows.map(([label, value]) => `
825
- <div>
826
- <span>${esc(label)}</span>
827
- <strong>${esc(value)}</strong>
828
- </div>
829
- `).join('')}
830
- </div>
831
- `;
832
- }
833
-
834
- function renderEnterpriseAdmin(payload) {
835
- latestEnterprise = payload || null;
836
- const enterprise = payload || {};
837
- const edition = enterprise.edition || {};
838
- const caps = edition.capabilities || {};
839
- const tags = document.getElementById('enterprise-status-tags');
840
- if (tags) {
841
- tags.innerHTML = [
842
- enterpriseStatusTag('edition', Boolean(edition.is_enterprise)),
843
- enterpriseStatusTag('policy packs', Boolean(enterprise.admin_policies?.enabled)),
844
- enterpriseStatusTag('siem', Boolean(enterprise.siem_export?.enabled)),
845
- ].join('');
846
- }
847
-
848
- const grid = document.getElementById('enterprise-capability-status');
849
- if (grid) {
850
- const entries = Object.keys(caps).length ? Object.entries(caps) : [];
851
- grid.innerHTML = entries.length ? entries.map(([name, enabled]) => `
852
- <div class="enterprise-cap-card ${enabled ? 'on' : 'off'}">
853
- <i class="ti ${enabled ? 'ti-circle-check' : 'ti-lock'}"></i>
854
- <span>${esc(name.replaceAll('_', ' '))}</span>
855
- <strong>${enabled ? 'enabled' : 'disabled'}</strong>
856
- </div>
857
- `).join('') : `<div class="preview" style="padding:14px">Capability status unavailable.</div>`;
858
- }
859
-
860
- const policies = enterprise.admin_policies || {};
861
- renderKeyValues('enterprise-admin-policies', [
862
- ['Capability', policies.capability || 'admin_policy_packs'],
863
- ['Enabled', Boolean(policies.enabled)],
864
- ['Enforced', Boolean(policies.enforced)],
865
- ['Base roles', (policies.effective_policy?.base_roles || []).join(', ')],
866
- ['Local file access', policies.effective_policy?.local_file_access || 'approval-token gated'],
867
- ['Package install', policies.effective_policy?.package_install || 'admin-only'],
868
- ['Note', policies.note || 'Community features remain available.'],
869
- ]);
870
-
871
- const org = enterprise.organization_settings || {};
872
- renderKeyValues('enterprise-org-settings', [
873
- ['Workspaces', (org.community_baseline?.workspaces || []).join(', ')],
874
- ['Roles', (org.community_baseline?.roles || []).join(', ')],
875
- ['Data isolation', org.community_baseline?.data_isolation || 'single-tenant local storage'],
876
- ['Governance enabled', Object.values(org.governance_capabilities || {}).filter(Boolean).length],
877
- ['Note', org.note || 'Enterprise governance is an extension point.'],
878
- ]);
879
-
880
- const audit = enterprise.audit_export || {};
881
- renderKeyValues('enterprise-audit-export', [
882
- ['Local export', audit.local_export?.available ? 'available' : 'unavailable'],
883
- ['Endpoint', audit.local_export?.endpoint || '/admin/security/export'],
884
- ['Formats', (audit.local_export?.formats || []).join(', ')],
885
- ['SIEM streaming', audit.siem_streaming?.enabled ? 'enabled' : 'disabled'],
886
- ['Retention', audit.compliance_retention?.enabled ? 'enabled' : 'disabled'],
887
- ]);
888
-
889
- const siem = enterprise.siem_export || {};
890
- renderKeyValues('enterprise-siem-export', [
891
- ['Capability', siem.capability || 'siem_export'],
892
- ['Enabled', Boolean(siem.enabled)],
893
- ['Streamed', Boolean(siem.streamed)],
894
- ['Destination', siem.destination || 'not configured'],
895
- ]);
896
- const preview = document.getElementById('enterprise-siem-preview');
897
- if (preview) preview.textContent = JSON.stringify(siem.preview_envelope || {}, null, 2);
898
- }
899
-
900
- async function refreshSiemPreview() {
901
- const res = await apiFetch('/admin/enterprise/siem-export', { headers: adminHeaders() });
902
- const data = res.ok ? await res.json() : {};
903
- renderEnterpriseAdmin({ ...(latestEnterprise || {}), siem_export: data });
904
- }
905
-
906
- function cellValue(value) {
907
- if (value === null || value === undefined) return '';
908
- if (Array.isArray(value)) return value.map(cellValue).filter(Boolean).join('; ');
909
- if (typeof value === 'object') return JSON.stringify(value);
910
- return String(value);
911
- }
912
-
913
- function csvCell(value) {
914
- const text = cellValue(value);
915
- return `"${text.replace(/"/g, '""')}"`;
916
- }
917
-
918
- function tableToCsv(headers, rows) {
919
- return [
920
- headers.map(csvCell).join(','),
921
- ...rows.map(row => headers.map(header => csvCell(row[header])).join(','))
922
- ].join('\r\n');
923
- }
924
-
925
- function tableToTxt(headers, rows) {
926
- return [
927
- headers.join('\t'),
928
- ...rows.map(row => headers.map(header => cellValue(row[header])).join('\t'))
929
- ].join('\r\n');
930
- }
931
-
932
- function htmlCell(value, tag = 'td') {
933
- return `<${tag}>${esc(cellValue(value))}</${tag}>`;
934
- }
935
-
936
- function tableToExcelHtml(title, sections) {
937
- const tables = sections.map(section => `
938
- <h2>${esc(section.title)}</h2>
939
- <table border="1">
940
- <thead><tr>${section.headers.map(header => htmlCell(header, 'th')).join('')}</tr></thead>
941
- <tbody>
942
- ${section.rows.map(row => `<tr>${section.headers.map(header => htmlCell(row[header])).join('')}</tr>`).join('')}
943
- </tbody>
944
- </table>
945
- `).join('<br>');
946
- return `<!doctype html>
947
- <html>
948
- <head>
949
- <meta charset="UTF-8">
950
- <style>
951
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
952
- table { border-collapse: collapse; }
953
- th, td { padding: 6px 8px; mso-number-format:"\\@"; }
954
- th { background: #efe8ff; font-weight: 700; }
955
- </style>
956
- </head>
957
- <body>
958
- <h1>${esc(title)}</h1>
959
- ${tables}
960
- </body>
961
- </html>`;
962
- }
963
-
964
- function downloadUtf8File(filename, content, mimeType) {
965
- const blob = new Blob([`\ufeff${content}`], { type: `${mimeType};charset=utf-8` });
966
- const url = URL.createObjectURL(blob);
967
- const link = document.createElement('a');
968
- link.href = url;
969
- link.download = filename;
970
- document.body.appendChild(link);
971
- link.click();
972
- link.remove();
973
- setTimeout(() => URL.revokeObjectURL(url), 1000);
974
- }
975
-
976
- function exportDateStamp() {
977
- const d = new Date();
978
- const pad = value => String(value).padStart(2, '0');
979
- return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
980
- }
981
-
982
- function securityExportSections() {
983
- const riskHeaders = ['type', 'time', 'user', 'email', 'sensitivity', 'labels', 'preview'];
984
- const complianceHeaders = ['type', 'time', 'user', 'email', 'sensitivity', 'fields', 'preview'];
985
- const riskRows = (latestSensitivity?.risk_fields || []).map(item => ({
986
- type: 'risk',
987
- time: formatTime(item.timestamp || item.created_at || item.time),
988
- user: item.user_nickname || 'Unknown',
989
- email: item.user_email || '',
990
- sensitivity: item.sensitivity || 'none',
991
- labels: item.labels || [],
992
- preview: item.preview || ''
993
- }));
994
- const complianceRows = (latestSensitivity?.compliance_fields || []).map(item => ({
995
- type: 'compliance',
996
- time: formatTime(item.timestamp || item.created_at || item.time),
997
- user: item.user_nickname || 'Unknown',
998
- email: item.user_email || '',
999
- sensitivity: item.sensitivity || 'none',
1000
- fields: item.compliance_fields || [],
1001
- preview: item.preview || ''
1002
- }));
1003
- return [
1004
- { title: currentLang === 'ko' ? '위험 필드' : 'Risk Fields', headers: riskHeaders, rows: riskRows },
1005
- { title: currentLang === 'ko' ? '준수 필드' : 'Compliance Fields', headers: complianceHeaders, rows: complianceRows }
1006
- ];
1007
- }
1008
-
1009
- function auditExportSections() {
1010
- const userHeaders = ['user', 'email', 'user_messages', 'assistant_messages', 'uploads', 'sensitive', 'high_risk', 'clear_events', 'delete_events', 'last_active'];
1011
- const eventHeaders = ['time', 'event', 'event_type', 'user', 'email', 'target_data', 'sensitivity', 'labels', 'preview'];
1012
- const userRows = (latestAudit?.per_user || []).map(user => ({
1013
- user: user.nickname || user.email || 'Unknown',
1014
- email: user.email || '',
1015
- user_messages: user.user_messages || 0,
1016
- assistant_messages: user.assistant_messages || 0,
1017
- uploads: user.document_uploads || 0,
1018
- sensitive: user.sensitive_events || 0,
1019
- high_risk: user.high_sensitive_events || 0,
1020
- clear_events: user.clear_events || 0,
1021
- delete_events: user.delete_events || 0,
1022
- last_active: formatTime(user.last_activity_at)
1023
- }));
1024
- const eventRows = (latestAudit?.recent_events || []).map(event => ({
1025
- time: formatTime(event.timestamp),
1026
- event: auditEventLabel(event),
1027
- event_type: event.event_type || '',
1028
- user: event.user_nickname || event.user_email || 'Unknown',
1029
- email: event.user_email || '',
1030
- target_data: auditTarget(event),
1031
- sensitivity: event.sensitivity || 'none',
1032
- labels: event.sensitive_labels || event.labels || event.compliance_fields || [],
1033
- preview: event.content_preview || event.preview || ''
1034
- }));
1035
- return [
1036
- { title: currentLang === 'ko' ? '사용자 사용량 및 위험도' : 'User Usage & Risk', headers: userHeaders, rows: userRows },
1037
- { title: currentLang === 'ko' ? '감사 이벤트' : 'Audit Trail', headers: eventHeaders, rows: eventRows }
1038
- ];
1039
- }
1040
-
1041
- function flattenSections(sections) {
1042
- const headers = Array.from(new Set(sections.flatMap(section => ['section', ...section.headers])));
1043
- const rows = sections.flatMap(section => section.rows.map(row => ({ section: section.title, ...row })));
1044
- return { headers, rows };
1045
- }
1046
-
1047
- function toggleExportOptions(scope) {
1048
- const options = document.getElementById(`${scope}-export-options`);
1049
- if (!options) return;
1050
- options.classList.toggle('open');
1051
- }
1052
-
1053
- function exportAdminLogs(scope, format) {
1054
- const isSecurity = scope === 'security';
1055
- const title = isSecurity ? t('sensitivity_title') : t('audit_title');
1056
- const sections = isSecurity ? securityExportSections() : auditExportSections();
1057
- if (!sections.some(section => section.rows.length)) {
1058
- alert(t('export_no_data'));
1059
- return;
1060
- }
1061
- const stamp = exportDateStamp();
1062
- const prefix = isSecurity ? 'lattice-security-monitoring' : 'lattice-audit-log';
1063
- if (format === 'excel') {
1064
- downloadUtf8File(`${prefix}-${stamp}.xls`, tableToExcelHtml(title, sections), 'application/vnd.ms-excel');
1065
- } else if (format === 'csv') {
1066
- const flat = flattenSections(sections);
1067
- downloadUtf8File(`${prefix}-${stamp}.csv`, tableToCsv(flat.headers, flat.rows), 'text/csv');
1068
- } else {
1069
- const content = sections.map(section => [
1070
- `[${section.title}]`,
1071
- tableToTxt(section.headers, section.rows)
1072
- ].join('\r\n')).join('\r\n\r\n');
1073
- downloadUtf8File(`${prefix}-${stamp}.txt`, content, 'text/plain');
1074
- }
1075
- document.getElementById(`${scope}-export-options`)?.classList.remove('open');
1076
- }
1077
-
1078
- function detectSsoTemplate(discoveryUrl = '') {
1079
- const url = discoveryUrl.toLowerCase();
1080
- if (url.includes('okta.com')) return 'okta';
1081
- if (url.includes('login.microsoftonline.com')) return 'entra';
1082
- return 'custom';
1083
- }
1084
-
1085
- function updateSsoTemplateHelp() {
1086
- const template = document.getElementById('sso-provider-template')?.value || 'custom';
1087
- const help = document.getElementById('sso-template-help');
1088
- if (!help) return;
1089
- const key = template === 'okta' ? 'sso_okta_help' : template === 'entra' ? 'sso_entra_help' : 'sso_custom_help';
1090
- help.textContent = t(key);
1091
- }
1092
-
1093
- function applySsoTemplate() {
1094
- const template = document.getElementById('sso-provider-template').value;
1095
- const provider = document.getElementById('sso-provider-name');
1096
- const discovery = document.getElementById('sso-discovery-url');
1097
- const redirect = document.getElementById('sso-redirect-uri');
1098
- if (template === 'okta') {
1099
- if (!provider.value || provider.value === 'Microsoft Entra ID') provider.value = 'Okta';
1100
- if (!discovery.value) discovery.value = 'https://your-domain.okta.com/oauth2/default/.well-known/openid-configuration';
1101
- } else if (template === 'entra') {
1102
- if (!provider.value || provider.value === 'Okta') provider.value = 'Microsoft Entra ID';
1103
- if (!discovery.value) discovery.value = 'https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration';
1104
- }
1105
- if (!redirect.value) redirect.value = `${location.origin}/auth/sso/callback`;
1106
- updateSsoTemplateHelp();
1107
- }
1108
-
1109
- function renderSso(config) {
1110
- latestSso = config || latestSso;
1111
- if (!latestSso) return;
1112
- document.getElementById('sso-provider-template').value = detectSsoTemplate(latestSso.discovery_url || '');
1113
- document.getElementById('sso-provider-name').value = latestSso.provider_name || '';
1114
- document.getElementById('sso-discovery-url').value = latestSso.discovery_url || '';
1115
- document.getElementById('sso-client-id').value = latestSso.client_id || '';
1116
- document.getElementById('sso-client-secret').value = '';
1117
- document.getElementById('sso-redirect-uri').value = latestSso.redirect_uri || `${location.origin}/auth/sso/callback`;
1118
- document.getElementById('sso-scopes').value = latestSso.scopes || 'openid email profile';
1119
- document.getElementById('sso-save-status').textContent = latestSso.enabled ? t('sso_ready') : t('sso_not_ready');
1120
- document.getElementById('sso-status-tags').innerHTML = [
1121
- [latestSso.enabled ? 'low' : 'medium', latestSso.enabled ? t('sso_ready') : t('sso_not_ready')],
1122
- [latestSso.secret_configured ? 'low' : 'medium', latestSso.secret_configured ? t('sso_secret_saved') : t('sso_secret_missing')],
1123
- ['medium', latestSso.provider_name || 'OIDC']
1124
- ].map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
1125
- updateSsoTemplateHelp();
1126
- }
1127
-
1128
- async function saveSso() {
1129
- const status = document.getElementById('sso-save-status');
1130
- status.textContent = t('vpc_saving');
1131
- const payload = {
1132
- enabled: true,
1133
- provider_name: document.getElementById('sso-provider-name').value.trim(),
1134
- discovery_url: document.getElementById('sso-discovery-url').value.trim(),
1135
- client_id: document.getElementById('sso-client-id').value.trim(),
1136
- client_secret: document.getElementById('sso-client-secret').value,
1137
- redirect_uri: document.getElementById('sso-redirect-uri').value.trim(),
1138
- scopes: document.getElementById('sso-scopes').value.trim() || 'openid email profile',
1139
- };
1140
- try {
1141
- const res = await apiFetch('/admin/sso', {
1142
- method: 'PATCH',
1143
- headers: adminHeaders(),
1144
- body: JSON.stringify(payload)
1145
- });
1146
- const data = await res.json().catch(() => ({}));
1147
- if (!res.ok) throw new Error(data.detail || t('vpc_save_fail'));
1148
- renderSso(data);
1149
- status.textContent = t('sso_saved');
1150
- } catch (e) {
1151
- status.textContent = e.message || t('vpc_save_fail');
1152
- }
1153
- }
1154
-
1155
- async function saveVpc() {
1156
- const payload = {
1157
- provider: document.getElementById('vpc-provider').value.trim(),
1158
- region: document.getElementById('vpc-region').value.trim(),
1159
- cidr_block: document.getElementById('vpc-cidr').value.trim(),
1160
- endpoint: document.getElementById('vpc-endpoint').value.trim(),
1161
- vpn_status: document.getElementById('vpc-vpn').value.trim(),
1162
- peering_status: document.getElementById('vpc-peering').value.trim(),
1163
- private_subnets: document.getElementById('vpc-subnets').value.split(',').map(v => v.trim()).filter(Boolean),
1164
- notes: document.getElementById('vpc-notes').value.trim()
1165
- };
1166
- const status = document.getElementById('vpc-save-status');
1167
- status.textContent = t('vpc_saving');
1168
- try {
1169
- const res = await apiFetch('/admin/vpc', {
1170
- method: 'PATCH',
1171
- headers: adminHeaders(),
1172
- body: JSON.stringify(payload)
1173
- });
1174
- const data = await res.json().catch(() => ({}));
1175
- if (!res.ok) throw new Error(data.detail || t('vpc_save_fail'));
1176
- fillVpcForm(data);
1177
- status.textContent = t('vpc_saved');
1178
- await loadDashboard();
1179
- } catch (e) {
1180
- status.textContent = e.message || t('vpc_save_fail');
1181
- }
1182
- }
1183
-
1184
- async function loadDashboard() {
1185
- applyI18n();
1186
- setSessionInfo();
1187
-
1188
- const access = document.getElementById('access-notice');
1189
- access.style.display = 'none';
1190
-
1191
- try {
1192
- const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes, ssoRes, enterpriseRes] = await Promise.all([
1193
- apiFetch('/health'),
1194
- apiFetch('/vpc/status'),
1195
- apiFetch('/admin/summary', { headers: adminHeaders() }),
1196
- apiFetch('/admin/users', { headers: adminHeaders() }),
1197
- apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
1198
- apiFetch('/admin/invite-link', { headers: adminHeaders() }),
1199
- apiFetch('/admin/stats', { headers: adminHeaders() }),
1200
- apiFetch('/admin/audit', { headers: adminHeaders() }),
1201
- apiFetch('/admin/sso', { headers: adminHeaders() }),
1202
- apiFetch('/admin/enterprise', { headers: adminHeaders() }),
1203
- ]);
1204
-
1205
- const health = healthRes.ok ? await healthRes.json() : null;
1206
- const vpc = vpcRes.ok ? await vpcRes.json() : null;
1207
- const summary = summaryRes.ok ? await summaryRes.json() : null;
1208
- const users = usersRes.ok ? await usersRes.json() : [];
1209
- const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
1210
- const invite = inviteRes.ok ? await inviteRes.json() : null;
1211
- const stats = statsRes.ok ? await statsRes.json() : null;
1212
- const audit = auditRes.ok ? await auditRes.json() : null;
1213
- const sso = ssoRes.ok ? await ssoRes.json() : null;
1214
- const enterprise = enterpriseRes.ok ? await enterpriseRes.json() : null;
1215
-
1216
- renderSummary(health, summary, vpc);
1217
- fillVpcForm(vpc);
1218
- renderUsers(users);
1219
- renderPermissions(users);
1220
- renderSensitivity(sensitivity);
1221
- renderAudit(audit);
1222
- renderSso(sso);
1223
- renderEnterpriseAdmin(enterprise);
1224
-
1225
- if (invite) {
1226
- document.getElementById('invite-link-input').value = invite.invite_url;
1227
- document.getElementById('invite-gate-info').textContent = invite.gate_enabled
1228
- ? `${t('invite_gate_active')} - ${invite.invite_code}`
1229
- : t('invite_gate_inactive');
1230
- }
1231
- if (stats) renderActivityChart(stats.daily);
1232
-
1233
- const failedSections = [];
1234
- if (!summaryRes.ok) failedSections.push(t('section_summary'));
1235
- if (!usersRes.ok) failedSections.push(t('section_users'));
1236
- if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
1237
- if (!auditRes.ok) failedSections.push(t('section_audit'));
1238
- if (!ssoRes.ok) failedSections.push(t('section_sso'));
1239
- if (!enterpriseRes.ok) failedSections.push('Enterprise');
1240
-
1241
- if (failedSections.length) {
1242
- access.style.display = 'block';
1243
- access.textContent = summaryRes.status === 403
1244
- ? t('err_no_admin')
1245
- : `${t('err_partial')} ${failedSections.join(', ')}`;
1246
- }
1247
- } catch (e) {
1248
- access.style.display = 'block';
1249
- access.textContent = !navigator.onLine
1250
- ? t('err_network')
1251
- : (e.message || t('err_load'));
1252
- }
1253
- }
1254
-
1255
- async function handleUserAction(event) {
1256
- const btn = event.target.closest('button[data-action]');
1257
- if (!btn) return;
1258
- const action = btn.dataset.action;
1259
- const email = btn.dataset.email;
1260
- if (!email) return;
1261
- const encodedEmail = encodeURIComponent(email);
1262
- if (action === 'role') {
1263
- await apiFetch(`/admin/users/${encodedEmail}`, {
1264
- method: 'PATCH',
1265
- headers: adminHeaders(),
1266
- body: JSON.stringify({ role: btn.dataset.nextRole })
1267
- });
1268
- await loadDashboard();
1269
- } else if (action === 'disable') {
1270
- await apiFetch(`/admin/users/${encodedEmail}`, {
1271
- method: 'PATCH',
1272
- headers: adminHeaders(),
1273
- body: JSON.stringify({ disabled: btn.dataset.disabled === 'true' })
1274
- });
1275
- await loadDashboard();
1276
- } else if (action === 'delete') {
1277
- if (!confirm(`'${email}' ${t('confirm_delete')}`)) return;
1278
- await apiFetch(`/admin/users/${encodedEmail}`, {
1279
- method: 'DELETE',
1280
- headers: adminHeaders()
1281
- });
1282
- await loadDashboard();
1283
- }
1284
- }
1285
-
1286
- async function logout() {
1287
- try {
1288
- await apiFetch('/logout', { method: 'POST' });
1289
- } catch (e) {}
1290
- localStorage.removeItem('ltcai_user_email');
1291
- localStorage.removeItem('ltcai_user_nickname');
1292
- localStorage.removeItem('ltcai_is_admin');
1293
- window.location.href = '/';
1294
- }
1295
-
1296
- restoreSessionFromQuery();
1297
- applyI18n();
1298
- initAdminNav();
1299
-
1300
- document.getElementById('refresh-btn').addEventListener('click', loadDashboard);
1301
- document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
1302
- document.getElementById('logout-btn').addEventListener('click', logout);
1303
- document.getElementById('copy-invite-btn').addEventListener('click', copyInviteLink);
1304
- document.getElementById('user-table-wrap').addEventListener('click', handleUserAction);
1305
- document.getElementById('permission-table-wrap').addEventListener('click', handleUserAction);
1306
- document.getElementById('sso-provider-template').addEventListener('change', applySsoTemplate);
1307
- document.getElementById('save-sso-btn').addEventListener('click', saveSso);
1308
- document.getElementById('test-sso-btn').addEventListener('click', () => {
1309
- window.location.href = `${API_BASE}/auth/sso/login`;
1310
- });
1311
- document.getElementById('refresh-siem-btn')?.addEventListener('click', () => refreshSiemPreview().catch(e => alert(String(e))));
1312
- document.getElementById('security-export-toggle')?.addEventListener('click', () => toggleExportOptions('security'));
1313
- document.getElementById('audit-export-toggle')?.addEventListener('click', () => toggleExportOptions('audit'));
1314
- document.querySelectorAll('[data-export-scope][data-export-format]').forEach(btn => {
1315
- btn.addEventListener('click', () => exportAdminLogs(btn.dataset.exportScope, btn.dataset.exportFormat));
1316
- });
1317
-
1318
- // ── Security & Audit Command Center (피드백 #5) ─────────────────────────────
1319
-
1320
- function ccEscape(value) {
1321
- if (value === null || value === undefined) return '';
1322
- const str = String(value);
1323
- return str
1324
- .replace(/&/g, '&amp;')
1325
- .replace(/</g, '&lt;')
1326
- .replace(/>/g, '&gt;')
1327
- .replace(/"/g, '&quot;')
1328
- .replace(/'/g, '&#39;');
1329
- }
1330
-
1331
- const CC_CARD_LABELS = {
1332
- events_today: '오늘 이벤트',
1333
- high_risk_events: 'High Risk',
1334
- risky_chats: '위험 채팅',
1335
- risky_files: '위험 파일',
1336
- secret_blocks: 'Secret 차단',
1337
- external_blocks: '외부 전송 차단',
1338
- admin_raw_views: '관리자 원문 조회',
1339
- review_required: '검토 필요',
1340
- };
1341
-
1342
- let ccUserChart = null;
1343
- let ccFieldChart = null;
1344
-
1345
- async function ccFetchJson(path) {
1346
- try {
1347
- const res = await apiFetch(path, { headers: adminHeaders() });
1348
- if (!res.ok) {
1349
- console.warn('Security CC fetch failed', path, res.status);
1350
- return null;
1351
- }
1352
- return await res.json();
1353
- } catch (e) {
1354
- console.warn('Security CC fetch error', path, e);
1355
- return null;
1356
- }
1357
- }
1358
-
1359
- function renderCcCards(overview) {
1360
- const root = document.getElementById('security-cc-cards');
1361
- if (!root || !overview || !overview.cards) return;
1362
- const html = Object.entries(overview.cards).map(([key, value]) => `
1363
- <div class="audit-card">
1364
- <div class="audit-card-label">${ccEscape(CC_CARD_LABELS[key] || key)}</div>
1365
- <div class="audit-card-value">${ccEscape(value)}</div>
1366
- </div>
1367
- `).join('');
1368
- root.innerHTML = html;
1369
- }
1370
-
1371
- function renderCcUsersTable(users) {
1372
- const wrap = document.getElementById('security-cc-users');
1373
- if (!wrap) return;
1374
- if (!users || users.length === 0) {
1375
- wrap.innerHTML = '<div class="preview" style="padding:14px">표시할 사용자가 없습니다.</div>';
1376
- return;
1377
- }
1378
- const rows = users.slice(0, 25).map(u => `
1379
- <tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
1380
- <td data-label="사용자">${ccEscape(u.user)}</td>
1381
- <td data-label="총 채팅">${ccEscape(u.total_chats)}</td>
1382
- <td data-label="준수 채팅" style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
1383
- <td data-label="위험 채팅" style="color:#b13030">${ccEscape(u.risky_chats)}</td>
1384
- <td data-label="총 파일">${ccEscape(u.uploaded_files)}</td>
1385
- <td data-label="준수 파일" style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
1386
- <td data-label="위험 파일" style="color:#b13030">${ccEscape(u.risky_files)}</td>
1387
- <td data-label="High">${ccEscape(u.high_risk_events)}</td>
1388
- <td data-label="위험률">${ccEscape(u.risk_rate)}%</td>
1389
- <td data-label="마지막 활동">${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
1390
- </tr>
1391
- `).join('');
1392
- wrap.innerHTML = `
1393
- <table class="data-table">
1394
- <thead><tr>
1395
- <th>사용자</th><th>총 채팅</th><th>준수 채팅</th><th>위험 채팅</th>
1396
- <th>총 파일</th><th>준수 파일</th><th>위험 파일</th>
1397
- <th>High</th><th>위험률</th><th>마지막 활동</th>
1398
- </tr></thead>
1399
- <tbody>${rows}</tbody>
1400
- </table>`;
1401
- wrap.querySelectorAll('tr[data-cc-user]').forEach(tr => {
1402
- tr.addEventListener('click', () => ccShowUserDrillDown(tr.dataset.ccUser));
1403
- });
1404
- }
1405
-
1406
- function renderCcUserChart(users) {
1407
- const canvas = document.getElementById('security-cc-user-chart');
1408
- if (!canvas || typeof Chart === 'undefined') return;
1409
- const top = users.slice(0, 8);
1410
- const labels = top.map(u => u.user);
1411
- if (ccUserChart) { ccUserChart.destroy(); ccUserChart = null; }
1412
- ccUserChart = new Chart(canvas, {
1413
- type: 'bar',
1414
- data: {
1415
- labels,
1416
- datasets: [
1417
- { label: '준수 채팅', backgroundColor: '#5cb874', data: top.map(u => u.compliant_chats) },
1418
- { label: '위험 채팅', backgroundColor: '#e8636e', data: top.map(u => u.risky_chats) },
1419
- { label: '준수 파일', backgroundColor: '#7fb5e6', data: top.map(u => u.compliant_files) },
1420
- { label: '위험 파일', backgroundColor: '#d94c4c', data: top.map(u => u.risky_files) },
1421
- ]
1422
- },
1423
- options: {
1424
- responsive: true,
1425
- scales: { x: { stacked: true }, y: { stacked: true } },
1426
- plugins: { legend: { position: 'bottom' } },
1427
- }
1428
- });
1429
- }
1430
-
1431
- function renderCcFieldChart(overview) {
1432
- const canvas = document.getElementById('security-cc-field-chart');
1433
- const legend = document.getElementById('security-cc-field-legend');
1434
- if (!canvas || typeof Chart === 'undefined') return;
1435
- const counts = overview?.field_counts || {};
1436
- const labels = Object.keys(counts);
1437
- const data = labels.map(l => counts[l]);
1438
- if (ccFieldChart) { ccFieldChart.destroy(); ccFieldChart = null; }
1439
- if (labels.length === 0) {
1440
- if (legend) legend.textContent = '감지된 민감정보 유형이 없습니다.';
1441
- return;
1442
- }
1443
- ccFieldChart = new Chart(canvas, {
1444
- type: 'doughnut',
1445
- data: { labels, datasets: [{ data, backgroundColor: ['#e8636e','#7fb5e6','#f0b14a','#5cb874','#9b6cd0','#3da9b6','#d18cd4','#a3a3a3'] }] },
1446
- options: { plugins: { legend: { position: 'bottom' } } }
1447
- });
1448
- if (legend) {
1449
- legend.innerHTML = labels.map((l, i) => `${ccEscape(l)}: ${ccEscape(data[i])}`).join(' · ');
1450
- }
1451
- }
1452
-
1453
- async function ccShowUserDrillDown(email) {
1454
- const data = await ccFetchJson(`/admin/security/events?user=${encodeURIComponent(email)}`);
1455
- const wrap = document.getElementById('security-cc-timeline');
1456
- if (!wrap) return;
1457
- const events = (data && data.events) || [];
1458
- if (!events.length) {
1459
- wrap.innerHTML = `<div class="preview" style="padding:14px">${ccEscape(email)} 사용자에 대한 이벤트가 없습니다.</div>`;
1460
- return;
1461
- }
1462
- const rows = events.slice(0, 40).map(e => `
1463
- <tr>
1464
- <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1465
- <td>${ccEscape(e.event_type || '')}</td>
1466
- <td>${ccEscape(e.sensitivity || 'none')}</td>
1467
- <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1468
- <td>${ccEscape((e.content_preview || '').slice(0, 80))}</td>
1469
- </tr>
1470
- `).join('');
1471
- wrap.innerHTML = `
1472
- <div style="margin-bottom:8px;color:var(--muted-text);font-size:12px">${ccEscape(email)} 사용자의 보안 이벤트 ${events.length}건</div>
1473
- <table class="data-table">
1474
- <thead><tr><th>시각</th><th>유형</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1475
- <tbody>${rows}</tbody>
1476
- </table>`;
1477
- }
1478
-
1479
- async function loadSecurityCommandCenter() {
1480
- const [overview, usersResp, eventsResp, filesResp] = await Promise.all([
1481
- ccFetchJson('/admin/security/overview'),
1482
- ccFetchJson('/admin/security/users'),
1483
- ccFetchJson('/admin/security/events?limit=50'),
1484
- ccFetchJson('/admin/security/files'),
1485
- ]);
1486
-
1487
- if (overview) {
1488
- renderCcCards(overview);
1489
- renderCcFieldChart(overview);
1490
- }
1491
- if (usersResp && Array.isArray(usersResp.users)) {
1492
- renderCcUsersTable(usersResp.users);
1493
- renderCcUserChart(usersResp.users);
1494
- }
1495
- if (eventsResp && Array.isArray(eventsResp.events)) {
1496
- const chats = eventsResp.events.filter(e => (e.sensitivity || 'none') !== 'none' && e.event_type === 'chat_message').slice(0, 20);
1497
- const chatWrap = document.getElementById('security-cc-chats');
1498
- if (chatWrap) {
1499
- chatWrap.innerHTML = chats.length ? `
1500
- <table class="data-table">
1501
- <thead><tr><th>시각</th><th>사용자</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1502
- <tbody>${chats.map(e => `
1503
- <tr>
1504
- <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1505
- <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1506
- <td>${ccEscape(e.sensitivity)}</td>
1507
- <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1508
- <td>${ccEscape((e.content_preview || '').slice(0, 100))}</td>
1509
- </tr>`).join('')}
1510
- </tbody>
1511
- </table>` : '<div class="preview" style="padding:14px">감지된 민감 채팅이 없습니다.</div>';
1512
- }
1513
- const timelineWrap = document.getElementById('security-cc-timeline');
1514
- if (timelineWrap && !timelineWrap.querySelector('table')) {
1515
- const rows = eventsResp.events.slice(0, 30).map(e => `
1516
- <tr>
1517
- <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1518
- <td>${ccEscape(e.event_type || '')}</td>
1519
- <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1520
- <td>${ccEscape(e.sensitivity || 'none')}</td>
1521
- </tr>
1522
- `).join('');
1523
- timelineWrap.innerHTML = rows ? `
1524
- <table class="data-table">
1525
- <thead><tr><th>시각</th><th>유형</th><th>사용자</th><th>민감도</th></tr></thead>
1526
- <tbody>${rows}</tbody>
1527
- </table>` : '<div class="preview" style="padding:14px">감사 이벤트가 없습니다.</div>';
1528
- }
1529
- }
1530
- if (filesResp && Array.isArray(filesResp.files)) {
1531
- const files = filesResp.files.filter(f => (f.sensitivity || 'none') !== 'none' || (f.sensitive_labels || []).length > 0).slice(0, 20);
1532
- const fileWrap = document.getElementById('security-cc-files');
1533
- if (fileWrap) {
1534
- fileWrap.innerHTML = files.length ? `
1535
- <table class="data-table">
1536
- <thead><tr><th>파일</th><th>업로드 사용자</th><th>민감도</th><th>라벨</th><th>크기</th></tr></thead>
1537
- <tbody>${files.map(f => `
1538
- <tr>
1539
- <td>${ccEscape(f.filename || f.file_id)}</td>
1540
- <td>${ccEscape(f.user_nickname || f.user_email || 'Unknown')}</td>
1541
- <td>${ccEscape(f.sensitivity || 'none')}</td>
1542
- <td>${ccEscape((f.sensitive_labels || []).join(', '))}</td>
1543
- <td>${ccEscape(f.bytes || '')}</td>
1544
- </tr>`).join('')}
1545
- </tbody>
1546
- </table>` : '<div class="preview" style="padding:14px">위험 등급 파일이 없습니다.</div>';
1547
- }
1548
- }
1549
- }
1550
-
1551
- async function ccLoadRaw(scope) {
1552
- const pre = document.getElementById('security-cc-raw');
1553
- if (!pre) return;
1554
- pre.textContent = '불러오는 중...';
1555
- try {
1556
- const res = await apiFetch(`/admin/security/raw?scope=${encodeURIComponent(scope)}`, { headers: adminHeaders() });
1557
- if (!res.ok) { pre.textContent = `요청 실패 (HTTP ${res.status})`; return; }
1558
- const text = await res.text();
1559
- try {
1560
- pre.textContent = JSON.stringify(JSON.parse(text), null, 2);
1561
- } catch (_) {
1562
- pre.textContent = text;
1563
- }
1564
- } catch (e) {
1565
- pre.textContent = String(e);
1566
- }
1567
- }
1568
-
1569
- async function ccExport(scope, format) {
1570
- try {
1571
- const res = await apiFetch('/admin/security/export', {
1572
- method: 'POST',
1573
- headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
1574
- body: JSON.stringify({ scope, format }),
1575
- });
1576
- if (!res.ok) {
1577
- alert('보안 리포트 추출 실패 (HTTP ' + res.status + ')');
1578
- return;
1579
- }
1580
- const blob = await res.blob();
1581
- const url = URL.createObjectURL(blob);
1582
- const a = document.createElement('a');
1583
- a.href = url;
1584
- a.download = `security_${scope}.${format === 'xlsx' ? 'xlsx' : format}`;
1585
- a.click();
1586
- setTimeout(() => URL.revokeObjectURL(url), 5000);
1587
- } catch (e) {
1588
- alert(String(e));
1589
- }
1590
- }
1591
-
1592
- document.getElementById('security-cc-export-toggle')?.addEventListener('click', () => {
1593
- const opts = document.getElementById('security-cc-export-options');
1594
- if (opts) opts.classList.toggle('open');
1595
- });
1596
- document.querySelectorAll('[data-cc-scope][data-cc-format]').forEach(btn => {
1597
- btn.addEventListener('click', () => ccExport(btn.dataset.ccScope, btn.dataset.ccFormat));
1598
- });
1599
- document.querySelectorAll('[data-cc-raw]').forEach(btn => {
1600
- btn.addEventListener('click', () => ccLoadRaw(btn.dataset.ccRaw));
1601
- });
1602
-
1603
- // 보안 탭 진입 시 자동 로드
1604
- document.querySelectorAll('[data-admin-nav="security"]').forEach(el => {
1605
- el.addEventListener('click', () => { setTimeout(loadSecurityCommandCenter, 50); });
1606
- });
1607
- // 메뉴 셀렉터를 모를 수도 있으니 hash 변경 시에도 시도
1608
- window.addEventListener('hashchange', () => {
1609
- if (location.hash.indexOf('security') >= 0) loadSecurityCommandCenter();
1610
- });
1611
-
1612
- loadDashboard();
1613
- // 보안 콘솔도 첫 진입 시 로드 시도 (실패해도 무해)
1614
- setTimeout(loadSecurityCommandCenter, 600);