| <!DOCTYPE html> |
| <html class="dark" lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta content="width=device-width, initial-scale=1.0" name="viewport"/> |
| <title>Admin Dashboard — Sydeco CRA</title> |
| <link rel="icon" type="image/svg+xml" href="images/sydeco_shield.svg"> |
| <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> |
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js" integrity="sha384-pb6hrQvo4s23cEUFtj0CZkzGE3jyK3pj26RIupXXxhSrrcUA/Cn0lZgcCrGH0t6L" crossorigin="anonymous"></script> |
| <script defer src="i18n.js"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/> |
| <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/> |
| <script id="tailwind-config"> |
| tailwind.config = { |
| darkMode: "class", |
| theme: { |
| extend: { |
| "colors": { |
| "on-background": "#dee2f4", |
| "outline-variant": "#4d4635", |
| "background": "#0e131f", |
| "on-error": "#690005", |
| "on-secondary-fixed": "#2d1600", |
| "secondary-fixed": "#ffdcbf", |
| "secondary": "#f1bc8c", |
| "primary": "#d4af37", |
| "inverse-on-surface": "#2b303d", |
| "on-primary": "#101828", |
| "on-primary-container": "#554300", |
| "on-secondary-fixed-variant": "#633f19", |
| "surface-tint": "#d4af37", |
| "on-tertiary-fixed-variant": "#414754", |
| "outline": "#99907c", |
| "inverse-surface": "#dee2f4", |
| "on-tertiary-container": "#3f4553", |
| "on-secondary-container": "#e2ae7f", |
| "primary-container": "#d4af37", |
| "tertiary": "#c9cedf", |
| "surface-container-highest": "#303542", |
| "on-tertiary-fixed": "#161c27", |
| "on-secondary": "#492905", |
| "primary-fixed": "#ffe088", |
| "tertiary-fixed-dim": "#c1c6d7", |
| "on-surface-variant": "#d0c5af", |
| "inverse-primary": "#735c00", |
| "surface-container": "#1b1f2c", |
| "surface-variant": "#303542", |
| "surface-container-high": "#252a37", |
| "surface-container-lowest": "#090e1a", |
| "on-primary-fixed-variant": "#574500", |
| "tertiary-container": "#adb3c3", |
| "tertiary-fixed": "#dde2f3", |
| "secondary-container": "#66411b", |
| "on-error-container": "#ffdad6", |
| "surface-bright": "#343946", |
| "error": "#ffb4ab", |
| "surface": "#0e131f", |
| "surface-container-low": "#161b28", |
| "on-primary-fixed": "#241a00", |
| "secondary-fixed-dim": "#f1bc8c", |
| "surface-dim": "#0e131f", |
| "on-tertiary": "#2a303d", |
| "on-surface": "#dee2f4", |
| "primary-fixed-dim": "#d4af37", |
| "error-container": "#93000a" |
| }, |
| "borderRadius": { |
| "DEFAULT": "4px", |
| "md": "8px", |
| "lg": "12px", |
| "xl": "16px", |
| "full": "9999px" |
| }, |
| "spacing": { |
| "margin-desktop": "64px", |
| "margin-tablet": "32px", |
| "margin-mobile": "16px", |
| "base": "8px", |
| "gutter": "24px", |
| "container-max": "1440px" |
| }, |
| "fontFamily": { |
| "display": ["Playfair Display", "serif"], |
| "body": ["Inter", "Plus Jakarta Sans", "sans-serif"], |
| "display-lg": ["Playfair Display"], |
| "body-md": ["Inter"], |
| "headline-lg-mobile": ["Playfair Display"], |
| "headline-md": ["Playfair Display"], |
| "label-sm": ["Inter"], |
| "headline-lg": ["Playfair Display"], |
| "label-md": ["Inter"], |
| "body-lg": ["Inter"] |
| }, |
| "fontSize": { |
| "display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}], |
| "body-md": ["16px", {"lineHeight": "1.5", "fontWeight": "400"}], |
| "headline-lg-mobile": ["28px", {"lineHeight": "1.2", "fontWeight": "600"}], |
| "headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}], |
| "label-sm": ["12px", {"lineHeight": "1.4", "fontWeight": "500"}], |
| "headline-lg": ["32px", {"lineHeight": "1.2", "fontWeight": "600"}], |
| "label-md": ["14px", {"lineHeight": "1.4", "letterSpacing": "0.05em", "fontWeight": "600"}], |
| "body-lg": ["18px", {"lineHeight": "1.6", "fontWeight": "400"}] |
| } |
| }, |
| }, |
| } |
| </script> |
| <style> |
| :root { |
| --bg-surface: rgba(27,31,44,0.75); |
| --bg-surface-elevated: rgba(37,42,55,0.85); |
| --border-color: rgba(212,175,55,0.1); |
| --color-low: #039855; --color-low-bg: rgba(3,152,85,0.12); |
| --color-medium: #DC6803; --color-medium-bg: rgba(220,104,3,0.12); |
| --color-high: #D92D20; --color-high-bg: rgba(217,45,32,0.12); |
| --color-critical: #7A271A; --color-critical-bg: rgba(122,39,26,0.12); |
| --transition-fast: 0.15s cubic-bezier(0.16,1,0.3,1); |
| } |
| body { |
| background-color: #0e131f; |
| color: #dee2f4; |
| font-family: 'Inter', sans-serif; |
| overflow-x: hidden; |
| } |
| .font-editorial { |
| font-family: 'Playfair Display', serif; |
| } |
| .material-symbols-outlined { |
| font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; |
| vertical-align: middle; |
| } |
| .obsidian-panel { |
| background: #161b28; |
| border: 1px solid rgba(212, 175, 55, 0.15); |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); |
| } |
| .gold-button { |
| background: #d4af37; |
| color: #101828; |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| .gold-button:hover:not(:disabled) { |
| transform: translateY(-1px); |
| box-shadow: 0 4px 20px rgba(212, 175, 55, 0.3); |
| filter: brightness(1.1); |
| } |
| .gold-button:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| .glass-card { |
| background: rgba(14, 19, 31, 0.4); |
| backdrop-filter: blur(20px); |
| border: 1px solid rgba(212, 175, 55, 0.1); |
| border-radius: 8px; |
| box-shadow: 0 8px 24px rgba(0,0,0,0.35); |
| } |
| .reports-table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 0.85rem; |
| text-align: left; |
| } |
| .reports-table th { |
| background: rgba(48, 53, 66, 0.45); |
| padding: 12px 16px; |
| font-weight: 700; |
| font-size: 0.72rem; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| color: #9ca3af; |
| border-bottom: 1px solid rgba(212, 175, 55, 0.1); |
| } |
| .reports-table td { |
| padding: 12px 16px; |
| border-bottom: 1px solid rgba(212, 175, 55, 0.1); |
| color: #dee2f4; |
| vertical-align: middle; |
| } |
| .reports-table tbody tr.row-clickable { |
| cursor: pointer; |
| transition: background var(--transition-fast); |
| } |
| .reports-table tbody tr.row-clickable:hover { |
| background: rgba(242, 202, 80, 0.03); |
| } |
| .reports-table tbody tr:last-child td { |
| border-bottom: none; |
| } |
| .tbl-pill { |
| display: inline-block; |
| padding: 3px 10px; |
| border-radius: 12px; |
| font-size: 0.68rem; |
| font-weight: 700; |
| letter-spacing: 0.03em; |
| text-transform: uppercase; |
| } |
| .tbl-pill-LOW { background: var(--color-low-bg); color: #039855; border: 1px solid rgba(6,95,70,0.2); } |
| .tbl-pill-MEDIUM { background: var(--color-medium-bg); color: #DC6803; border: 1px solid rgba(180,83,9,0.2); } |
| .tbl-pill-HIGH { background: var(--color-high-bg); color: #D92D20; border: 1px solid rgba(153,27,27,0.2); } |
| .tbl-pill-CRITICAL { background: var(--color-critical-bg); color: #ffdad6; border: 1px solid rgba(127,29,29,0.2); } |
| .tbl-pill-PENDING { background: rgba(212,175,55,0.15); color: #d4af37; border: 1px solid rgba(212,175,55,0.3); } |
| .tbl-pill-FAILED { background: rgba(153,27,27,0.15); color: #D92D20; border: 1px solid rgba(153,27,27,0.3); } |
| |
| .score-cell { |
| font-family: 'Playfair Display', serif; |
| font-weight: 700; |
| font-size: 0.95rem; |
| } |
| .score-cell.risk-LOW { color: #039855; } |
| .score-cell.risk-MEDIUM { color: #DC6803; } |
| .score-cell.risk-HIGH { color: #D92D20; } |
| .score-cell.risk-CRITICAL { color: #ffb4ab; } |
| |
| .mono-badge { |
| font-family: monospace; |
| font-size: 0.7rem; |
| padding: 2px 6px; |
| border-radius: 4px; |
| background: rgba(48, 53, 66, 0.5); |
| border: 1px solid rgba(212, 175, 55, 0.1); |
| color: #9ca3af; |
| } |
| .fill-LOW { background-color: var(--color-low); } |
| .fill-MEDIUM { background-color: var(--color-medium); } |
| .fill-HIGH { background-color: var(--color-high); } |
| .fill-CRITICAL { background-color: var(--color-critical); } |
| |
| .spinner-ring { |
| width: 36px; |
| height: 36px; |
| border: 3px solid rgba(212,175,55,0.15); |
| border-top-color:#d4af37; |
| border-radius:50%; |
| animation:spin 0.9s linear infinite; |
| } |
| @keyframes spin { to{transform:rotate(360deg)} } |
| |
| .custom-scrollbar::-webkit-scrollbar { |
| width: 4px; |
| } |
| .custom-scrollbar::-webkit-scrollbar-track { |
| background: rgba(31, 41, 55, 0.5); |
| } |
| .custom-scrollbar::-webkit-scrollbar-thumb { |
| background: #D4AF37; |
| border-radius: 2px; |
| } |
| |
| |
| .nav-link-active { |
| color: #d4af37 !important; |
| background-color: rgba(212, 175, 55, 0.1) !important; |
| border-left: 3px solid #d4af37; |
| } |
| </style> |
| </head> |
| <body class="bg-background text-on-background font-body selection:bg-primary/30" x-data="adminDashboard()" x-init="load()"> |
|
|
| <button type="button" data-lang-toggle onclick="i18n.toggleLang()" |
| class="fixed top-3 right-3 z-[60] px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider border border-primary/40 bg-surface-container/90 text-primary hover:bg-primary hover:text-on-primary transition-colors backdrop-blur-sm" |
| aria-label="Toggle language / Ganti bahasa">ID</button> |
|
|
| |
| <header class="fixed top-0 left-0 lg:left-64 right-0 z-40 h-16 flex items-center justify-between gap-3 px-4 sm:px-6 border-b border-outline-variant/10 shadow-sm" style="background:rgba(14,19,31,0.9);backdrop-filter:blur(20px);"> |
| <div class="flex items-center gap-2 min-w-0 text-xs font-semibold text-on-surface-variant/60 font-body"> |
| <button type="button" class="lg:hidden flex items-center justify-center w-11 h-11 -ml-2 rounded text-on-surface-variant hover:text-primary transition-colors shrink-0" aria-label="Open navigation menu" onclick="toggleSidebar()"> |
| <span class="material-symbols-outlined">menu</span> |
| </button> |
| <a href="/admin" class="hover:text-primary transition-colors text-on-surface-variant/80 hidden sm:inline shrink-0" style="text-decoration:none;" data-i18n="nav.sidebar.admin_portal">Admin Portal</a> |
| <span class="material-symbols-outlined text-[16px] text-on-surface-variant/30 hidden sm:inline shrink-0">chevron_right</span> |
| <span class="text-primary font-bold truncate" x-text="$store.lang && getTabTitle()">System Administration</span> |
| </div> |
| <a href="/" class="text-sm font-bold font-body uppercase tracking-wider px-3 sm:px-5 py-2 rounded bg-primary text-on-primary hover:brightness-110 transition-all shadow-md shadow-primary/10 whitespace-nowrap shrink-0" style="text-decoration:none;"><span data-i18n="admin.header.new_analysis_prefix">+ New</span><span class="hidden sm:inline" data-i18n="admin.header.new_analysis_suffix"> Analysis</span></a> |
| </header> |
|
|
| |
| <div id="sidebar-overlay" class="fixed inset-0 bg-black/60 z-[45] hidden lg:hidden" onclick="toggleSidebar()"></div> |
| <aside id="sidebar" class="fixed left-0 top-0 bottom-0 w-64 flex flex-col py-6 bg-surface-container/95 lg:bg-surface-container/40 backdrop-blur-2xl border-r border-outline-variant/10 shadow-2xl z-50 -translate-x-full lg:translate-x-0 transition-transform duration-200"> |
| <div class="px-6 mb-8 flex items-center gap-3 pt-4"> |
| <div class="w-10 h-10 rounded bg-primary/20 flex items-center justify-center border border-primary/30 shrink-0"> |
| <svg class="h-6 w-6" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| <path d="M100 20L40 50V100C40 137.01 65.59 171.41 100 180C134.41 171.41 160 137.01 160 100V50L100 20Z" stroke="#D4AF37" stroke-width="8" stroke-linejoin="round"/> |
| <path d="M100 60V140" stroke="#D4AF37" stroke-width="8" stroke-linecap="round"/> |
| <path d="M70 80L130 80" stroke="#D4AF37" stroke-width="8" stroke-linecap="round"/> |
| <path d="M80 120L120 120" stroke="#D4AF37" stroke-width="8" stroke-linecap="round"/> |
| </svg> |
| </div> |
| <div> |
| <p class="font-editorial text-primary leading-tight text-[18px]">Sydeco CRA</p> |
| <p class="font-body text-[10px] text-on-surface-variant uppercase tracking-widest opacity-70 font-semibold">LightML Engine</p> |
| </div> |
| </div> |
| |
| |
| <nav class="flex-grow flex flex-col gap-1 font-body"> |
| |
| <div class="px-6 mb-2 mt-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/40" data-i18n="nav.sidebar.core_operations">Core Operations</div> |
| <a class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all" href="/"> |
| <span class="material-symbols-outlined">upload_file</span> |
| <span class="text-sm font-semibold" data-i18n="nav.new_analysis">New Analysis</span> |
| </a> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| :class="activeTab === 'overview' ? 'nav-link-active' : ''" @click="activeTab = 'overview'; window.location.hash = 'overview'"> |
| <span class="material-symbols-outlined">analytics</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.overview">Overview</span> |
| </button> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| :class="activeTab === 'history' ? 'nav-link-active' : ''" @click="activeTab = 'history'; window.location.hash = 'history'; fetchHistory()"> |
| <span class="material-symbols-outlined">folder_open</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.case_history">Case History</span> |
| </button> |
|
|
| |
| <div class="px-6 mb-2 mt-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/40" x-show="role === 'admin' || role === 'manager'" data-i18n="nav.sidebar.management">Management</div> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| x-show="role === 'admin' || role === 'manager'" |
| :class="activeTab === 'team' ? 'nav-link-active' : ''" @click="activeTab = 'team'; window.location.hash = 'team'"> |
| <span class="material-symbols-outlined">group</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.team_management">Team Management</span> |
| </button> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| x-show="role === 'admin'" |
| :class="activeTab === 'orgs' ? 'nav-link-active' : ''" @click="activeTab = 'orgs'; window.location.hash = 'orgs'"> |
| <span class="material-symbols-outlined">corporate_fare</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.organizations">Organizations</span> |
| </button> |
|
|
| |
| <div class="px-6 mb-2 mt-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/40" x-show="role === 'admin' || role === 'reviewer'" data-i18n="nav.sidebar.compliance_health">Compliance & Health</div> |
| <a class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all" href="/citations" |
| x-show="role === 'admin' || role === 'reviewer'"> |
| <span class="material-symbols-outlined">verified</span> |
| <span class="text-sm font-semibold" data-i18n="nav.citation_library">Citation Library</span> |
| </a> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| x-show="role === 'admin'" |
| :class="activeTab === 'audits' ? 'nav-link-active' : ''" @click="activeTab = 'audits'; window.location.hash = 'audits'"> |
| <span class="material-symbols-outlined">receipt_long</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.audit_logs">Audit Logs</span> |
| </button> |
| <button class="flex items-center gap-3 px-4 py-3 mx-2 text-on-surface-variant hover:bg-surface-variant/30 hover:text-primary rounded-lg transition-all text-left" |
| x-show="role === 'admin'" |
| :class="activeTab === 'status' ? 'nav-link-active' : ''" @click="activeTab = 'status'; window.location.hash = 'status'"> |
| <span class="material-symbols-outlined">dns</span> |
| <span class="text-sm font-semibold" data-i18n="nav.sidebar.system_health">System Health</span> |
| </button> |
| </nav> |
|
|
| <div class="px-6 mt-auto font-body"> |
| <div class="flex flex-col gap-3 pt-6 border-t border-outline-variant/10"> |
| <a class="flex items-center gap-3 text-on-surface-variant hover:text-primary transition-colors text-xs" href="/account" style="text-decoration:none;"> |
| <span class="material-symbols-outlined text-[20px]">shield_person</span> |
| <span data-i18n="nav.sidebar.account_security">Account & Security</span> |
| </a> |
| <a class="flex items-center gap-3 text-on-surface-variant hover:text-primary transition-colors text-xs" href="/logout" style="text-decoration:none;"> |
| <span class="material-symbols-outlined text-[20px]">logout</span> |
| <span data-i18n="nav.secure_logout">Secure Logout</span> |
| </a> |
| </div> |
| </div> |
| </aside> |
|
|
| |
| <div class="lg:ml-64 min-h-screen"> |
| <main class="max-w-[1200px] mx-auto px-4 sm:px-6 pb-12 pt-24"> |
|
|
| |
| <div class="mb-4 bg-red-950/20 border border-red-500/30 rounded px-4 py-3 text-red-300 text-sm font-body" |
| x-show="errorMsg" x-text="errorMsg" style="display:none;" x-transition></div> |
|
|
| <div class="mb-4 bg-teal-950/20 border border-teal-500/30 rounded px-4 py-3 text-teal-300 text-sm font-body" |
| x-show="successMsg" x-text="successMsg" style="display:none;" x-transition></div> |
|
|
| |
| <div class="flex flex-col items-center gap-4 py-20 text-on-surface-variant" x-show="loading"> |
| <div class="spinner-ring"></div> |
| <p class="font-body text-sm" data-i18n="admin.loading.aggregating">Aggregating system statistics…</p> |
| </div> |
|
|
| |
| <div x-show="!loading" x-cloak style="display:none;" :style="{ display: loading ? 'none' : 'block' }"> |
| |
| |
| <div x-show="activeTab === 'overview'" class="space-y-8 animate-fade-in"> |
| <div class="mb-4"> |
| <h1 class="font-editorial text-3xl mb-2 text-on-background" data-i18n="admin.overview.title">System Overview</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.overview.subtitle">Monitor historical document assessments, risk ratings, and pipeline operations.</p> |
| </div> |
|
|
| |
| <div class="grid grid-cols-2 lg:grid-cols-4 gap-gutter"> |
| <div class="glass-card p-6 flex flex-col gap-2 relative overflow-hidden group"> |
| <span class="text-xs font-bold uppercase tracking-widest text-on-surface-variant font-body" data-i18n="admin.overview.stat_documents_label">Stored Documents</span> |
| <span class="font-editorial text-4xl font-bold" x-text="stats.total_documents ?? 0">—</span> |
| <span class="text-xs text-on-surface-variant font-body" data-i18n="admin.overview.stat_documents_desc">Retained active agreements</span> |
| </div> |
| <div class="glass-card p-6 flex flex-col gap-2 relative overflow-hidden group"> |
| <span class="text-xs font-bold uppercase tracking-widest text-on-surface-variant font-body" data-i18n="admin.overview.stat_analyses_label">Analyses Executed</span> |
| <span class="font-editorial text-4xl font-bold" x-text="stats.total_analyses ?? 0">—</span> |
| <span class="text-xs text-on-surface-variant font-body" data-i18n="admin.overview.stat_analyses_desc">Completed model jobs</span> |
| </div> |
| <div class="glass-card p-6 flex flex-col gap-2 relative overflow-hidden group"> |
| <span class="text-xs font-bold uppercase tracking-widest text-on-surface-variant font-body" data-i18n="admin.overview.stat_risk_label">Average Risk Index</span> |
| <span class="font-editorial text-4xl font-bold text-primary" x-text="stats.average_risk_score ?? 0">—</span> |
| <span class="text-xs text-on-surface-variant font-body" data-i18n="admin.overview.stat_risk_desc">Combined L3 score metric</span> |
| </div> |
| <div class="glass-card p-6 flex flex-col gap-2 relative overflow-hidden group"> |
| <span class="text-xs font-bold uppercase tracking-widest text-on-surface-variant font-body" data-i18n="admin.overview.stat_risky_label">Risky Agreements</span> |
| <span class="font-editorial text-4xl font-bold text-error" x-text="getRiskyCount()">—</span> |
| <span class="text-xs text-on-surface-variant font-body" data-i18n="admin.overview.stat_risky_desc">Flagged HIGH or CRITICAL</span> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6"> |
| |
| <div class="glass-card p-6 min-w-0"> |
| <h2 class="font-editorial font-bold text-base mb-5 flex items-center gap-2"> |
| <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:#d4af37;stroke-width:2px;"> |
| <path d="M21.21 15.89A10 10 0 1 1 8 2.83" stroke-linecap="round" stroke-linejoin="round"/> |
| <path d="M22 12A10 10 0 0 0 12 2v10z" stroke-linecap="round" stroke-linejoin="round"/> |
| </svg> |
| <span data-i18n="admin.overview.distribution_title">Risk Class Distribution</span> |
| </h2> |
| <div class="flex flex-col gap-5 font-body"> |
| <template x-for="lbl in ['LOW','MEDIUM','HIGH','CRITICAL']"> |
| <div class="flex flex-col gap-2"> |
| <div class="flex justify-between items-center text-sm font-semibold"> |
| <span class="text-on-surface-variant" x-text="lbl"></span> |
| <span class="mono-badge" x-text="stats.distribution ? stats.distribution[lbl] || 0 : 0"></span> |
| </div> |
| <div class="h-3 rounded-full overflow-hidden" style="background:rgba(48, 53, 66, 0.45); border:1px solid rgba(212, 175, 55, 0.1);"> |
| <div class="h-full rounded-full transition-all duration-1000" :class="'fill-' + lbl" |
| :style="{ width: getPercentage(stats.distribution ? stats.distribution[lbl] || 0 : 0) + '%' }"></div> |
| </div> |
| </div> |
| </template> |
| </div> |
| </div> |
|
|
| |
| <div class="glass-card p-6 min-w-0"> |
| <h2 class="font-editorial font-bold text-base mb-5 flex items-center gap-2"> |
| <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:#d4af37;stroke-width:2px;"> |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round"/> |
| </svg> |
| <span data-i18n="admin.overview.recent_title">Recent Risk Reports</span> |
| </h2> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30 custom-scrollbar"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.overview.col_ref_id">Ref ID</th> |
| <th data-i18n="admin.overview.col_filename">Filename</th> |
| <th data-i18n="admin.overview.col_type">Type</th> |
| <th data-i18n="admin.overview.col_jurisdiction">Jurisdiction</th> |
| <th data-i18n="admin.overview.col_score">Score</th> |
| <th data-i18n="admin.overview.col_risk_rating">Risk Rating</th> |
| <th data-i18n="admin.overview.col_analyzed">Analyzed</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-if="recent.length === 0"> |
| <tr><td colspan="7" style="text-align:center;color:#9ca3af;font-style:italic;padding:24px;" data-i18n="admin.overview.empty_reports">No legal reports processed in history.</td></tr> |
| </template> |
| <template x-for="row in recent"> |
| <tr class="row-clickable" @click="window.location='/result/' + row.id"> |
| <td style="font-family:monospace;color:#9ca3af;" x-text="row.id ? row.id.substring(0,8) : '—'"></td> |
| <td style="font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#dee2f4;" :title="row.original_filename" x-text="row.original_filename"></td> |
| <td><span class="mono-badge" x-text="(row.file_type || '').replace('.','').toUpperCase()"></span></td> |
| <td x-text="row.jurisdiction || '—'"></td> |
| <td class="score-cell" :class="'risk-' + (row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : 'PENDING'))" |
| x-text="row.status === 'completed' ? (row.risk_score ?? '—') : '—'"></td> |
| <td> |
| <span class="tbl-pill flex items-center justify-center gap-1.5 w-max" |
| :style="{ minWidth: '85px' }" |
| :class="'tbl-pill-' + (row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : 'PENDING'))"> |
| <span class="material-symbols-outlined text-[12px] animate-spin" x-show="row.status === 'processing' || row.status === 'queued'">sync</span> |
| <span x-text="row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : (row.status === 'processing' ? 'PROCESSING' : 'QUEUED'))"></span> |
| </span> |
| </td> |
| <td style="color:#9ca3af;" x-text="timeAgo(row.analyzed_at)"></td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'history'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.history.title">Case History</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.history.subtitle">Search, filter, and organize historical contract risk reports.</p> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-4"> |
| <h2 class="font-editorial font-bold text-base text-primary flex items-center gap-2"> |
| <span class="material-symbols-outlined">filter_alt</span> |
| <span data-i18n="admin.history.filter_title">Filter Documents</span> |
| </h2> |
| <form @submit.prevent="historyFilters.offset = 0; fetchHistory()" class="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.search_label">General Search</label> |
| <input type="text" x-model="historyFilters.search" placeholder="Filename, error..." data-i18n-placeholder="admin.history.search_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"/> |
| </div> |
|
|
| |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.client_label">Client Name</label> |
| <input type="text" x-model="historyFilters.client" placeholder="e.g. Acme Corp" data-i18n-placeholder="admin.history.client_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"/> |
| </div> |
|
|
| |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.folder_label">Case / Folder</label> |
| <input type="text" x-model="historyFilters.case_folder" placeholder="e.g. Q3 NDA" data-i18n-placeholder="admin.history.folder_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"/> |
| </div> |
|
|
| |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.doc_type_label">Document Type</label> |
| <select x-model="historyFilters.type" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"> |
| <option value="" data-i18n="admin.history.type_all">All Types</option> |
| <option value="nda" data-i18n="admin.history.type_nda">Non-Disclosure Agreement</option> |
| <option value="employment" data-i18n="admin.history.type_employment">Employment Contract</option> |
| <option value="service" data-i18n="admin.history.type_service">Service Agreement</option> |
| <option value="software" data-i18n="admin.history.type_software">Software Licensing</option> |
| <option value="generic" data-i18n="admin.history.type_generic">Generic/Other</option> |
| </select> |
| </div> |
|
|
| |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.status_label">Job Status</label> |
| <select x-model="historyFilters.status" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"> |
| <option value="" data-i18n="admin.history.status_all">All Statuses</option> |
| <option value="completed" data-i18n="admin.history.status_completed">Completed</option> |
| <option value="processing" data-i18n="admin.history.status_processing">Processing</option> |
| <option value="queued" data-i18n="admin.history.status_queued">Queued</option> |
| <option value="failed" data-i18n="admin.history.status_failed">Failed</option> |
| </select> |
| </div> |
|
|
| |
| <div class="grid grid-cols-2 gap-2"> |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.min_score_label">Min Score</label> |
| <input type="number" x-model="historyFilters.min_score" min="0" max="100" placeholder="0" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"/> |
| </div> |
| <div class="flex flex-col gap-1.5"> |
| <label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/70" data-i18n="admin.history.max_score_label">Max Score</label> |
| <input type="number" x-model="historyFilters.max_score" min="0" max="100" placeholder="100" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-3 py-2 rounded text-sm"/> |
| </div> |
| </div> |
|
|
| |
| <div class="md:col-span-3 flex justify-end gap-3 pt-2"> |
| <button type="button" @click="historyFilters = { search: '', client: '', case_folder: '', type: '', status: '', min_score: '', max_score: '', limit: 25, offset: 0 }; fetchHistory()" |
| class="px-4 py-2 border border-outline-variant/40 rounded text-xs font-semibold" data-i18n="admin.history.clear_button">Clear Filters</button> |
| <button type="submit" class="gold-button px-6 py-2 rounded text-xs font-bold uppercase tracking-wider" data-i18n="admin.history.search_button">Search</button> |
| </div> |
| </form> |
| </div> |
|
|
| |
| <div class="glass-card p-6"> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30 custom-scrollbar"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.history.col_ref_id">Ref ID</th> |
| <th data-i18n="admin.history.col_filename">Filename</th> |
| <th data-i18n="admin.history.col_client">Client</th> |
| <th data-i18n="admin.history.col_folder">Folder/Case</th> |
| <th data-i18n="admin.history.col_score">Score</th> |
| <th data-i18n="admin.history.col_status">Status</th> |
| <th data-i18n="admin.history.col_reviewed">Reviewed</th> |
| <th data-i18n="admin.history.col_analyzed">Analyzed</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-if="historyResults.length === 0"> |
| <tr><td colspan="8" style="text-align:center;color:#9ca3af;font-style:italic;padding:24px;" data-i18n="admin.history.empty">No historical analyses match your query.</td></tr> |
| </template> |
| <template x-for="row in historyResults"> |
| <tr class="row-clickable" @click="window.location='/result/' + row.id"> |
| <td style="font-family:monospace;color:#9ca3af;" x-text="row.id ? row.id.substring(0,8) : '—'"></td> |
| <td style="font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#dee2f4;" :title="row.original_filename" x-text="row.original_filename"></td> |
| <td x-text="row.client || '—'"></td> |
| <td x-text="row.case_folder || '—'"></td> |
| <td class="score-cell" :class="'risk-' + (row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : 'PENDING'))" |
| x-text="row.status === 'completed' ? (row.risk_score ?? '—') : '—'"></td> |
| <td> |
| <span class="tbl-pill flex items-center justify-center gap-1.5 w-max" |
| :style="{ minWidth: '85px' }" |
| :class="'tbl-pill-' + (row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : 'PENDING'))"> |
| <span x-text="row.status === 'completed' ? row.risk_label : (row.status === 'failed' ? 'FAILED' : (row.status === 'processing' ? 'PROCESSING' : 'QUEUED'))"></span> |
| </span> |
| </td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border" |
| :class="row.review_status !== 'unreviewed' ? 'bg-teal-950/40 text-teal-400 border-teal-500/20' : 'bg-surface-container-highest text-on-surface-variant border-outline-variant/10'" |
| x-text="row.review_status !== 'unreviewed' ? row.review_status : 'no'"></span> |
| </td> |
| <td style="color:#9ca3af;" x-text="timeAgo(row.analyzed_at)"></td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
|
|
| |
| <div class="flex justify-between items-center mt-5 text-xs text-on-surface-variant font-body"> |
| <button type="button" :disabled="historyFilters.offset === 0" @click="historyFilters.offset = Math.max(0, historyFilters.offset - historyFilters.limit); fetchHistory()" |
| class="px-4 py-2 border border-outline-variant/20 rounded disabled:opacity-40 disabled:cursor-not-allowed" data-i18n="admin.history.prev_page">Previous Page</button> |
| <button type="button" :disabled="historyResults.length < historyFilters.limit" @click="historyFilters.offset += historyFilters.limit; fetchHistory()" |
| class="px-4 py-2 border border-outline-variant/20 rounded disabled:opacity-40 disabled:cursor-not-allowed" data-i18n="admin.history.next_page">Next Page</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'team'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div class="flex justify-between items-center"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.team.title">Team Management</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.team.subtitle">Provision users, assign organizational access roles, and audit security credentials.</p> |
| </div> |
| <button class="gold-button px-5 py-2.5 rounded font-bold text-xs uppercase tracking-wider flex items-center gap-2" |
| @click="showCreateUserModal = true"> |
| <span class="material-symbols-outlined text-[16px]">person_add</span> |
| <span data-i18n="admin.team.create_button">Create User</span> |
| </button> |
| </div> |
|
|
| |
| <div class="glass-card p-6"> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.team.col_id">ID</th> |
| <th data-i18n="admin.team.col_email">Email / Identity</th> |
| <th data-i18n="admin.team.col_role">Role</th> |
| <th data-i18n="admin.team.col_org_id">Org ID</th> |
| <th data-i18n="admin.team.col_mfa_setup">MFA Setup</th> |
| <th data-i18n="admin.team.col_downloads">Downloads</th> |
| <th data-i18n="admin.team.col_status">Status</th> |
| <th style="text-align:right;" data-i18n="admin.team.col_actions">Actions</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-for="usr in users" :key="usr.id"> |
| <tr> |
| <td style="font-family:monospace;color:#9ca3af;" x-text="usr.id"></td> |
| <td class="font-bold text-on-surface" x-text="usr.email"></td> |
| <td> |
| <select :value="usr.role" @change="changeUserRole(usr.id, $event.target.value)" |
| class="bg-surface-container border border-outline-variant/20 rounded text-xs px-2 py-1 text-on-surface focus:outline-none"> |
| <option value="analyst" data-i18n="admin.team.role_analyst">Analyst</option> |
| <option value="reviewer" data-i18n="admin.team.role_reviewer">Reviewer</option> |
| <option value="manager" data-i18n="admin.team.role_manager">Manager</option> |
| <option value="viewer" data-i18n="admin.team.role_viewer">Viewer</option> |
| <option value="admin" data-i18n="admin.team.role_admin">Administrator</option> |
| </select> |
| </td> |
| <td x-text="usr.org_id"></td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase" |
| :class="usr.mfa_enabled ? 'bg-green-950/40 text-green-400 border border-green-500/20' : 'bg-amber-950/40 text-amber-300 border border-amber-500/20'" |
| x-text="$store.lang && (usr.mfa_enabled ? i18n.t('admin.team.mfa_active') : i18n.t('admin.team.mfa_unset'))"></span> |
| </td> |
| <td> |
| <select :value="usr.download_disabled ? '1' : '0'" @change="changeUserDownload(usr.id, $event.target.value)" |
| class="bg-surface-container border border-outline-variant/20 rounded text-xs px-2 py-1 text-on-surface focus:outline-none"> |
| <option value="0" data-i18n="admin.team.download_enabled">Enabled</option> |
| <option value="1" data-i18n="admin.team.download_disabled">Disabled</option> |
| </select> |
| </td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase" |
| :class="usr.active ? 'bg-teal-950/40 text-teal-400 border border-teal-500/20' : 'bg-red-950/40 text-red-400 border border-red-500/20'" |
| x-text="$store.lang && (usr.active ? i18n.t('admin.team.status_active') : i18n.t('admin.team.status_suspended'))"></span> |
| </td> |
| <td style="text-align:right;" class="space-x-1"> |
| <button class="px-2 py-1 rounded bg-surface-container border border-outline-variant/30 text-[10px] font-bold uppercase tracking-wider text-on-surface hover:border-primary/50 transition-colors" |
| @click="resetUserMfa(usr.id)" data-i18n="admin.team.reset_mfa_button">Reset MFA</button> |
| <button class="px-2 py-1 rounded bg-surface-container border border-outline-variant/30 text-[10px] font-bold uppercase tracking-wider text-on-surface hover:border-primary/50 transition-colors" |
| @click="revokeUserSession(usr.email)" data-i18n="admin.team.revoke_session_button">Revoke Session</button> |
| <button class="px-2 py-1 rounded border text-[10px] font-bold uppercase tracking-wider transition-colors" |
| :class="usr.active ? 'bg-red-950/20 hover:bg-red-900/40 text-red-400 border-red-500/20' : 'bg-teal-950/20 hover:bg-teal-900/40 text-teal-400 border-teal-500/20'" |
| x-text="$store.lang && (usr.active ? i18n.t('admin.team.suspend_button') : i18n.t('admin.team.activate_button'))" |
| @click="toggleUserStatus(usr.id, usr.active)"></button> |
| </td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'orgs'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div class="flex justify-between items-center"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.orgs.title">Organizations</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.orgs.subtitle">Provision corporate tenants, check storage retention rules, and define scan limitations.</p> |
| </div> |
| <button class="gold-button px-5 py-2.5 rounded font-bold text-xs uppercase tracking-wider flex items-center gap-2" |
| @click="showCreateOrgModal = true"> |
| <span class="material-symbols-outlined text-[16px]">domain_add</span> |
| <span data-i18n="admin.orgs.create_button">Create Org</span> |
| </button> |
| </div> |
|
|
| |
| <div class="glass-card p-6"> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.orgs.col_org_id">Org ID</th> |
| <th data-i18n="admin.orgs.col_name">Corporate Tenant Name</th> |
| <th data-i18n="admin.orgs.col_retention">Data Retention Policy</th> |
| <th data-i18n="admin.orgs.col_mfa_enforcement">MFA Enforcement</th> |
| <th data-i18n="admin.orgs.col_scan_limits">Monthly Scan Limits</th> |
| <th data-i18n="admin.orgs.col_status">Status</th> |
| <th style="text-align:right;" data-i18n="admin.orgs.col_save">Save Configurations</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-for="org in organizations" :key="org.id"> |
| <tr> |
| <td style="font-family:monospace;color:#9ca3af;" x-text="org.id"></td> |
| <td class="font-bold text-on-surface" x-text="org.name"></td> |
| <td> |
| <select :value="org.retention_days || 30" @change="tempRetention[org.id] = $event.target.value" |
| class="bg-surface-container border border-outline-variant/20 rounded text-xs px-2 py-1 text-on-surface focus:outline-none"> |
| <option value="7" data-i18n="admin.orgs.retention_7">7-Day Ephemeral Purge</option> |
| <option value="15" data-i18n="admin.orgs.retention_15">15-Day Auto-Purge</option> |
| <option value="30" data-i18n="admin.orgs.retention_30">30-Day Storage Lock</option> |
| <option value="90" data-i18n="admin.orgs.retention_90">90-Day Standard Log</option> |
| </select> |
| </td> |
| <td> |
| <button type="button" |
| class="px-2 py-0.5 rounded text-[10px] font-bold uppercase border transition-colors" |
| :class="org.mfa_required ? 'bg-teal-950/40 text-teal-400 border-teal-500/20' : 'bg-surface-container border-outline-variant/30 text-on-surface-variant hover:border-primary/40'" |
| @click="toggleOrgMfaRequired(org)" |
| x-text="$store.lang && (org.mfa_required ? i18n.t('admin.orgs.mfa_enforced') : i18n.t('admin.orgs.mfa_optional'))"></button> |
| </td> |
| <td> |
| <select class="bg-surface-container border border-outline-variant/20 rounded text-xs px-2 py-1 text-on-surface focus:outline-none"> |
| <option value="50" data-i18n="admin.orgs.scan_50">50 scans / mo</option> |
| <option value="100" selected data-i18n="admin.orgs.scan_100">100 scans / mo</option> |
| <option value="500" data-i18n="admin.orgs.scan_500">500 scans / mo</option> |
| <option value="9999" data-i18n="admin.orgs.scan_unlimited">Unlimited Scans</option> |
| </select> |
| </td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-950/40 text-teal-400 border border-teal-500/20" data-i18n="admin.orgs.status_active">Active</span> |
| </td> |
| <td style="text-align:right;"> |
| <button class="gold-button px-4 py-1.5 rounded text-[10px] font-bold uppercase tracking-wider" |
| @click="saveOrgRetention(org.id)" data-i18n="admin.orgs.update_button">Update Policy</button> |
| </td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'citations'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.citations.title">Citation Library workspace</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.citations.subtitle">Review, verify, reject, or supersede draft legal source citations mapped by standard rules.</p> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-6"> |
| <h2 class="font-editorial text-lg font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.citations.pending_title">Pending Draft Citations</h2> |
|
|
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.citations.col_finding_id">Finding ID</th> |
| <th data-i18n="admin.citations.col_jurisdiction">Jurisdiction</th> |
| <th data-i18n="admin.citations.col_article_ref">Article Reference</th> |
| <th data-i18n="admin.citations.col_source_desc">Legal Source Description</th> |
| <th data-i18n="admin.citations.col_verifier_note">Verifier Note</th> |
| <th data-i18n="admin.citations.col_status">Status</th> |
| <th style="text-align:right;" data-i18n="admin.citations.col_decision">Decision Workspace</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-for="cite in draftCitations" :key="cite.finding_id + cite.jurisdiction"> |
| <tr> |
| <td style="font-family:monospace;color:#dee2f4;" x-text="cite.finding_id"></td> |
| <td class="font-semibold" x-text="cite.jurisdiction"></td> |
| <td class="font-bold text-primary" x-text="cite.article"></td> |
| <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;" x-text="cite.source"></td> |
| <td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;color:#9ca3af;" x-text="cite.note"></td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-amber-950/40 text-amber-300 border border-amber-500/20" |
| x-text="cite.status"></span> |
| </td> |
| <td style="text-align:right;" class="space-x-1"> |
| <button class="px-2.5 py-1 rounded bg-teal-950/20 hover:bg-teal-900/40 text-teal-400 border border-teal-500/20 text-[10px] font-bold uppercase transition-colors" |
| @click="verifyCitation(cite)" data-i18n="admin.citations.verify_button">Verify</button> |
| <button class="px-2.5 py-1 rounded bg-red-950/20 hover:bg-red-900/40 text-red-400 border border-red-500/20 text-[10px] font-bold uppercase transition-colors" |
| @click="rejectCitation(cite)" data-i18n="admin.citations.reject_button">Reject</button> |
| <button class="px-2.5 py-1 rounded bg-surface-container border border-outline-variant/30 text-[10px] font-bold uppercase text-on-surface hover:border-primary/50 transition-colors" |
| @click="supersedeCitation(cite)" data-i18n="admin.citations.supersede_button">Supersede</button> |
| <button class="px-2.5 py-1 rounded bg-surface-container border border-outline-variant/30 text-[10px] font-bold uppercase text-on-surface hover:border-primary/50 transition-colors" |
| @click="viewCitationHistory(cite)" data-i18n="admin.citations.history_button">History</button> |
| </td> |
| </tr> |
| </template> |
| <template x-if="draftCitations.length === 0"> |
| <tr><td colspan="7" style="text-align:center;color:#9ca3af;font-style:italic;padding:24px;" data-i18n="admin.citations.empty_draft">No draft citations pending verification.</td></tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-6"> |
| <h2 class="font-editorial text-lg font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.citations.verified_title">Verified Citation Index</h2> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.citations.col_finding_id">Finding ID</th> |
| <th data-i18n="admin.citations.col_jurisdiction">Jurisdiction</th> |
| <th data-i18n="admin.citations.col_article">Article</th> |
| <th data-i18n="admin.citations.col_legal_code_ref">Legal Code Reference</th> |
| <th data-i18n="admin.citations.col_status">Status</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-for="cite in verifiedCitations" :key="cite.finding_id + cite.jurisdiction"> |
| <tr> |
| <td style="font-family:monospace;color:#9ca3af;" x-text="cite.finding_id"></td> |
| <td x-text="cite.jurisdiction"></td> |
| <td class="font-semibold text-teal-400" x-text="cite.article"></td> |
| <td x-text="cite.source"></td> |
| <td> |
| <span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-950/40 text-teal-400 border border-teal-500/20" data-i18n="admin.citations.status_verified">Verified</span> |
| </td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'audits'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div class="flex justify-between items-center"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.audits.title">System Audit Log</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.audits.subtitle">Filter, search, and export security event records for local institutional auditing.</p> |
| </div> |
| <button class="btn btn-outline text-xs uppercase font-bold tracking-wider flex items-center gap-2" |
| @click="exportAuditLogs()"> |
| <span class="material-symbols-outlined text-[16px]">download</span> |
| <span data-i18n="admin.audits.export_button">Export Log (CSV)</span> |
| </button> |
| </div> |
|
|
| |
| <div class="flex flex-wrap gap-4 p-4 bg-surface-container-low border border-outline-variant/15 rounded-md text-xs font-body"> |
| <div class="flex-grow min-w-[200px] relative flex items-center"> |
| <span class="material-symbols-outlined text-[18px] text-on-surface-variant/40 absolute left-3">search</span> |
| <input type="text" x-model="auditFilters.search" placeholder="Search logs by action or details…" data-i18n-placeholder="admin.audits.search_placeholder" |
| class="w-full bg-surface-container border border-outline-variant/20 rounded pl-10 pr-3 py-2 text-xs text-on-surface focus:outline-none focus:border-primary/50 focus:ring-0"/> |
| </div> |
| <div> |
| <select x-model="auditFilters.action" class="bg-surface-container border border-outline-variant/20 rounded px-3 py-2 text-xs text-on-surface focus:outline-none"> |
| <option value="all" data-i18n="admin.audits.action_all">All Actions</option> |
| <option value="user.create">user.create</option> |
| <option value="user.suspend">user.suspend</option> |
| <option value="user.mfa_reset">user.mfa_reset</option> |
| <option value="org.create">org.create</option> |
| <option value="org.retention_change">org.retention_change</option> |
| <option value="cite.verify">cite.verify</option> |
| </select> |
| </div> |
| <div> |
| <input type="date" x-model="auditFilters.date" class="bg-surface-container border border-outline-variant/20 rounded px-3 py-2 text-xs text-on-surface focus:outline-none"/> |
| </div> |
| <button class="px-4 py-2 border border-outline-variant/30 text-on-surface-variant hover:text-primary hover:border-primary/50 rounded transition-colors" |
| @click="clearAuditFilters()" data-i18n="admin.audits.reset_button">Reset</button> |
| </div> |
|
|
| |
| <div class="glass-card p-6"> |
| <div class="overflow-x-auto rounded-lg border border-outline-variant/10 bg-surface-container-lowest/30"> |
| <table class="reports-table"> |
| <thead> |
| <tr class="font-body text-xs text-on-surface-variant"> |
| <th data-i18n="admin.audits.col_timestamp">Timestamp</th> |
| <th data-i18n="admin.audits.col_action">Action</th> |
| <th data-i18n="admin.audits.col_operator_id">Operator ID</th> |
| <th data-i18n="admin.audits.col_org_id">Org ID</th> |
| <th data-i18n="admin.audits.col_resource_target">Resource Target</th> |
| <th data-i18n="admin.audits.col_ip">IP Address</th> |
| <th data-i18n="admin.audits.col_detail">Detail Summary</th> |
| </tr> |
| </thead> |
| <tbody class="font-body text-xs"> |
| <template x-for="log in filteredAudits"> |
| <tr> |
| <td style="color:#9ca3af;" x-text="formatDate(log.timestamp)"></td> |
| <td class="font-bold text-primary" x-text="log.action"></td> |
| <td style="font-family:monospace;" x-text="'User #' + log.user_id"></td> |
| <td x-text="log.org_id"></td> |
| <td style="font-family:monospace;" x-text="log.resource_id || '—'"></td> |
| <td x-text="log.ip"></td> |
| <td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;" x-text="log.detail || '—'"></td> |
| </tr> |
| </template> |
| <template x-if="filteredAudits.length === 0"> |
| <tr><td colspan="7" style="text-align:center;color:#9ca3af;font-style:italic;padding:24px;" data-i18n="admin.audits.empty">No matching audit events logged.</td></tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div x-show="activeTab === 'status'" class="space-y-6 animate-fade-in" style="display:none;"> |
| <div> |
| <h1 class="font-editorial text-3xl mb-1 text-on-background" data-i18n="admin.status.title">System Health status</h1> |
| <p class="font-body text-on-surface-variant text-sm" data-i18n="admin.status.subtitle">Monitor local encryption cores, rate limit configs, backup schedules, and active ML models.</p> |
| </div> |
|
|
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6 font-body text-sm"> |
|
|
| |
| <div class="glass-card p-6 space-y-4"> |
| <h2 class="font-editorial text-base font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.status.overview_title">Status overview</h2> |
| <div class="space-y-3"> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.sovereign_health_label">Sovereign health:</span> |
| <span class="px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider" |
| :class="healthData.status === 'healthy' ? 'bg-teal-950/40 text-teal-400 border border-teal-500/20' : 'bg-red-950/40 text-red-400 border border-red-500/20'" |
| x-text="healthData.status || 'unknown'"></span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.database_label">Localized Database:</span> |
| <span class="text-on-surface font-semibold" x-text="healthData.checks ? healthData.checks.database : '—'"></span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.datasets_label">Datasets verification:</span> |
| <span class="text-on-surface font-semibold" x-text="healthData.checks ? healthData.checks.datasets : '—'"></span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-4"> |
| <h2 class="font-editorial text-base font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.status.encryption_title">Encryption Core</h2> |
| <div class="space-y-3"> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.aes_label">AES-256 state:</span> |
| <span class="px-2 py-0.5 rounded text-xs font-bold uppercase border" |
| :class="healthData.encryption && healthData.encryption.enabled ? 'bg-teal-950/40 text-teal-400 border-teal-500/20' : 'bg-red-950/40 text-red-400 border-red-500/20'" |
| x-text="$store.lang && (healthData.encryption && healthData.encryption.enabled ? i18n.t('admin.status.aes_configured') : i18n.t('admin.status.aes_not_configured'))"></span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.storage_node_label">Storage Encryption Node:</span> |
| <span class="text-on-surface-variant" data-i18n="admin.status.storage_node_value">PT SYDECO Sovereign Memory Module</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-4"> |
| <h2 class="font-editorial text-base font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.status.models_title">Active PyTorch Models</h2> |
| <div class="space-y-3 text-xs"> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.l2_label">L2 DistilBERT Classifier:</span> |
| <span class="font-semibold text-teal-400" x-text="$store.lang && (healthData.layer2_distilbert ? i18n.t('admin.status.l2_ready') : i18n.t('admin.status.l2_unavailable'))"></span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.l4_label">L4 Qwen Reasoning:</span> |
| <span class="font-semibold text-teal-400" x-text="$store.lang && (healthData.layer4_qwen ? i18n.t('admin.status.l4_available') : i18n.t('admin.status.l4_disabled'))"></span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.mlp_label">Sydeco MLP Network:</span> |
| <span class="font-semibold text-teal-400" x-text="$store.lang && (healthData.sydeco_mlp ? i18n.t('admin.status.mlp_online') : i18n.t('admin.status.mlp_offline'))"></span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="glass-card p-6 space-y-4"> |
| <h2 class="font-editorial text-base font-bold text-primary border-b border-outline-variant/10 pb-2" data-i18n="admin.status.limits_title">Limits & Backups</h2> |
| <div class="space-y-3"> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.rate_limiter_label">API Rate Limiter:</span> |
| <span class="text-on-surface font-semibold" data-i18n="admin.status.rate_limiter_value">Active (Max 20 req/min)</span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.last_backup_label">Last System Backup:</span> |
| <span class="text-on-surface font-semibold">2026-06-29T23:59:59Z</span> |
| </div> |
| <div class="flex justify-between items-center"> |
| <span data-i18n="admin.status.backup_sync_label">Backup Node Sync:</span> |
| <span class="text-teal-400 font-bold" data-i18n="admin.status.backup_sync_value">Synced</span> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| </div> |
|
|
| |
| <div class="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 font-body" x-show="showCreateUserModal" style="display:none;" x-transition> |
| <div class="obsidian-panel w-full max-w-md p-8 rounded-lg space-y-6"> |
| <header> |
| <h3 class="font-editorial text-xl text-on-surface font-bold" data-i18n="admin.modals.create_user.title">Create User Account</h3> |
| <p class="text-xs text-on-surface-variant mt-1" data-i18n="admin.modals.create_user.subtitle">Configure credentials, roles, and corporate tenant associations.</p> |
| </header> |
| <div class="space-y-4 text-xs font-body"> |
| <div class="flex flex-col gap-1"> |
| <label class="font-semibold text-on-surface-variant uppercase tracking-wider" data-i18n="admin.modals.create_user.email_label">Email Identity</label> |
| <input type="email" x-model="createUserForm.email" placeholder="user@company.com" data-i18n-placeholder="admin.modals.create_user.email_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-4 py-2.5 rounded text-sm"/> |
| </div> |
| <div class="flex flex-col gap-1"> |
| <label class="font-semibold text-on-surface-variant uppercase tracking-wider" data-i18n="admin.modals.create_user.password_label">Secure password</label> |
| <input type="password" x-model="createUserForm.password" placeholder="••••••••" data-i18n-placeholder="admin.modals.create_user.password_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-4 py-2.5 rounded text-sm"/> |
| </div> |
| <div class="flex flex-col gap-1"> |
| <label class="font-semibold text-on-surface-variant uppercase tracking-wider" data-i18n="admin.modals.create_user.role_label">Assign Role</label> |
| <select x-model="createUserForm.role" class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-4 py-2.5 rounded text-sm focus:outline-none"> |
| <option value="analyst" data-i18n="admin.team.role_analyst">Analyst</option> |
| <option value="reviewer" data-i18n="admin.team.role_reviewer">Reviewer</option> |
| <option value="manager" data-i18n="admin.team.role_manager">Manager</option> |
| <option value="viewer" data-i18n="admin.team.role_viewer">Viewer</option> |
| <option value="admin" data-i18n="admin.team.role_admin">Administrator</option> |
| </select> |
| </div> |
| <div class="flex flex-col gap-1"> |
| <label class="font-semibold text-on-surface-variant uppercase tracking-wider" data-i18n="admin.modals.create_user.org_id_label">Corporate Organization ID</label> |
| <input type="number" x-model.number="createUserForm.org_id" placeholder="1" data-i18n-placeholder="admin.modals.create_user.org_id_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-4 py-2.5 rounded text-sm"/> |
| </div> |
| </div> |
| <div class="flex justify-end gap-3 pt-4 border-t border-outline-variant/10"> |
| <button type="button" class="px-5 py-2.5 rounded border border-outline-variant/40 hover:bg-surface-container text-xs font-semibold text-on-surface-variant transition-colors" |
| @click="showCreateUserModal = false" data-i18n="admin.modals.create_user.cancel_button">Cancel</button> |
| <button type="button" class="gold-button px-6 py-2.5 rounded text-xs font-bold transition-all" |
| @click="createUser()" data-i18n="admin.modals.create_user.submit_button">Create User</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 font-body" x-show="showCreateOrgModal" style="display:none;" x-transition> |
| <div class="obsidian-panel w-full max-w-md p-8 rounded-lg space-y-6"> |
| <header> |
| <h3 class="font-editorial text-xl text-on-surface font-bold" data-i18n="admin.modals.create_org.title">Create Corporate Tenant</h3> |
| <p class="text-xs text-on-surface-variant mt-1" data-i18n="admin.modals.create_org.subtitle">Register a new sovereign corporate organization domain.</p> |
| </header> |
| <div class="space-y-4 text-xs font-body"> |
| <div class="flex flex-col gap-1"> |
| <label class="font-semibold text-on-surface-variant uppercase tracking-wider" data-i18n="admin.modals.create_org.name_label">Corporate Tenant Name</label> |
| <input type="text" x-model="createOrgForm.name" placeholder="Company Name Inc." data-i18n-placeholder="admin.modals.create_org.name_placeholder" |
| class="bg-surface-container-lowest border border-outline-variant/30 text-on-surface px-4 py-2.5 rounded text-sm"/> |
| </div> |
| </div> |
| <div class="flex justify-end gap-3 pt-4 border-t border-outline-variant/10"> |
| <button type="button" class="px-5 py-2.5 rounded border border-outline-variant/40 hover:bg-surface-container text-xs font-semibold text-on-surface-variant transition-colors" |
| @click="showCreateOrgModal = false" data-i18n="admin.modals.create_org.cancel_button">Cancel</button> |
| <button type="button" class="gold-button px-6 py-2.5 rounded text-xs font-bold transition-all" |
| @click="createOrg()" data-i18n="admin.modals.create_org.submit_button">Create Tenant</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 font-body" x-show="showHistoryModal" style="display:none;" x-transition> |
| <div class="obsidian-panel w-full max-w-lg p-8 rounded-lg space-y-6"> |
| <header> |
| <h3 class="font-editorial text-xl text-on-surface font-bold" data-i18n="admin.modals.citation_history.title">Citation Revision History</h3> |
| <p class="text-xs text-on-surface-variant mt-1" x-text="selectedCitation ? (selectedCitation.finding_id + ' / ' + selectedCitation.jurisdiction) : ''"></p> |
| </header> |
|
|
| <div class="space-y-4 text-xs font-body leading-relaxed max-h-60 overflow-y-auto custom-scrollbar pr-2"> |
| <div class="flex flex-col gap-3"> |
| <div class="border-l-2 border-primary pl-4 py-2 space-y-1"> |
| <div class="flex justify-between font-bold text-on-surface"> |
| <span data-i18n="admin.modals.citation_history.status_verified">Status: verified</span> |
| <span class="text-on-surface-variant/40">2026-06-30 08:30</span> |
| </div> |
| <p class="text-on-surface-variant text-[11px]" x-text="selectedCitation ? ('Article referenced: ' + selectedCitation.article) : ''"></p> |
| <p class="text-[10px] text-teal-400 font-semibold" data-i18n="admin.modals.citation_history.verified_note">Transitioned to verified baseline by System Administrator.</p> |
| </div> |
| <div class="border-l-2 border-outline-variant/30 pl-4 py-2 space-y-1"> |
| <div class="flex justify-between font-bold text-on-surface-variant"> |
| <span data-i18n="admin.modals.citation_history.status_draft">Status: draft</span> |
| <span class="text-on-surface-variant/40">2026-06-28 14:15</span> |
| </div> |
| <p class="text-on-surface-variant text-[11px]" data-i18n="admin.modals.citation_history.draft_note">Draft provision parsed via machine extraction.</p> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="flex justify-end pt-4 border-t border-outline-variant/10"> |
| <button type="button" class="gold-button px-6 py-2.5 rounded text-xs font-bold transition-all" |
| @click="showHistoryModal = false" data-i18n="admin.modals.citation_history.close_button">Close History</button> |
| </div> |
| </div> |
| </div> |
|
|
| </main> |
| </div> |
|
|
| <script> |
| function adminDashboard() { |
| return { |
| loading: true, |
| activeTab: 'overview', |
| role: 'analyst', |
| usage: null, |
| stats: {}, |
| recent: [], |
| users: [], |
| organizations: [], |
| citations: [], |
| audits: [], |
| healthData: {}, |
| tempRetention: {}, |
| historyResults: [], |
| historyFilters: { search: '', client: '', case_folder: '', type: '', status: '', min_score: '', max_score: '', limit: 25, offset: 0 }, |
| |
| |
| showCreateUserModal: false, |
| showCreateOrgModal: false, |
| showHistoryModal: false, |
| selectedCitation: null, |
| createUserForm: { email: '', password: '', role: 'analyst', org_id: 1 }, |
| createOrgForm: { name: '' }, |
| |
| |
| errorMsg: '', |
| successMsg: '', |
| |
| |
| auditFilters: { search: '', action: 'all', date: '' }, |
| |
| getTabTitle() { |
| const titles = { |
| overview: i18n.t('nav.sidebar.overview'), |
| history: i18n.t('nav.sidebar.case_history'), |
| team: i18n.t('nav.sidebar.team_management'), |
| orgs: i18n.t('nav.sidebar.organizations'), |
| citations: i18n.t('admin.tab_titles.citation_verification'), |
| audits: i18n.t('nav.sidebar.audit_logs'), |
| status: i18n.t('nav.sidebar.system_health') |
| }; |
| return titles[this.activeTab] || i18n.t('nav.sidebar.overview'); |
| }, |
| |
| async load() { |
| this.loading = true; |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const roleResp = await fetch('/api/v1/mfa/status'); |
| if (roleResp.status === 401) { window.location.href = '/login'; return; } |
| const roleData = await roleResp.json(); |
| this.role = roleData.role || 'analyst'; |
| |
| |
| const hash = window.location.hash.substring(1); |
| let validTabs = ['overview', 'history']; |
| if (this.role === 'admin') { |
| validTabs = ['overview', 'history', 'team', 'orgs', 'citations', 'audits', 'status']; |
| } else if (this.role === 'manager') { |
| validTabs = ['overview', 'history', 'team']; |
| } |
| |
| if (validTabs.includes(hash)) { |
| this.activeTab = hash; |
| } else { |
| this.activeTab = 'overview'; |
| window.location.hash = 'overview'; |
| } |
| |
| const fetches = [ |
| fetch('/api/v1/stats'), |
| fetch('/api/v1/recent?limit=20') |
| ]; |
| |
| if (this.role === 'admin') { |
| fetches.push( |
| fetch('/api/v1/admin/users'), |
| fetch('/api/v1/admin/organizations'), |
| fetch('/api/v1/citations'), |
| fetch('/api/v1/audit?limit=150'), |
| fetch('/health') |
| ); |
| } else if (this.role === 'manager') { |
| fetches.push( |
| fetch('/api/v1/admin/users'), |
| fetch('/api/v1/usage') |
| ); |
| } |
| |
| const results = await Promise.all(fetches); |
| |
| if (results[0].status === 401 || results[1].status === 401) { |
| window.location.href = '/login'; return; |
| } |
| |
| this.stats = await results[0].json(); |
| this.recent = await results[1].json(); |
| |
| let idx = 2; |
| if (this.role === 'admin') { |
| this.users = await results[idx++].json(); |
| this.organizations = await results[idx++].json(); |
| this.citations = await results[idx++].json(); |
| this.audits = await results[idx++].json(); |
| this.healthData = await results[idx++].json(); |
| |
| |
| this.organizations.forEach(o => { |
| this.tempRetention[o.id] = o.retention_days || 30; |
| o.mfa_required = !!o.mfa_required; |
| }); |
| } else if (this.role === 'manager') { |
| this.users = await results[idx++].json(); |
| this.usage = await results[idx++].json(); |
| } |
| |
| this.loading = false; |
| } catch (e) { |
| console.error(e); |
| this.loading = false; |
| this.errorMsg = 'Error loading administration data from localized endpoints.'; |
| } |
| }, |
| |
| async fetchHistory() { |
| this.loading = true; |
| this.errorMsg = ''; |
| try { |
| const queryParams = new URLSearchParams(); |
| for (const [k, v] of Object.entries(this.historyFilters)) { |
| if (v !== '' && v !== null && v !== undefined) { |
| queryParams.append(k, v); |
| } |
| } |
| const resp = await fetch(`/api/v1/history?${queryParams.toString()}`); |
| if (resp.ok) { |
| this.historyResults = await resp.json(); |
| } else { |
| this.errorMsg = 'Failed to fetch case history.'; |
| } |
| } catch (e) { |
| this.errorMsg = 'Error communicating with history API.'; |
| } finally { |
| this.loading = false; |
| } |
| }, |
| |
| |
| getRiskyCount() { |
| const dist = this.stats.distribution || {}; |
| return (dist['HIGH'] || 0) + (dist['CRITICAL'] || 0); |
| }, |
| |
| getPercentage(count) { |
| const dist = this.stats.distribution || {}; |
| const total = Object.values(dist).reduce((a, b) => a + b, 0) || 1; |
| return Math.round((count / total) * 100); |
| }, |
| |
| timeAgo(dateStr) { |
| if (!dateStr) return '—'; |
| const d = new Date(dateStr + 'Z'); |
| const sec = Math.floor((Date.now() - d) / 1000); |
| if (sec < 60) return 'Just now'; |
| if (sec < 3600) return Math.floor(sec/60) + 'm ago'; |
| if (sec < 86400) return Math.floor(sec/3600) + 'h ago'; |
| return d.toLocaleDateString(); |
| }, |
| |
| |
| async createUser() { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch('/api/v1/admin/users', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(this.createUserForm) |
| }); |
| const data = await resp.json(); |
| if (!resp.ok) { |
| this.errorMsg = data.error || 'Failed to create user account.'; |
| return; |
| } |
| this.successMsg = `User account ${this.createUserForm.email} provisioned successfully.`; |
| this.showCreateUserModal = false; |
| this.createUserForm = { email: '', password: '', role: 'analyst', org_id: 1 }; |
| |
| const usersResp = await fetch('/api/v1/admin/users'); |
| this.users = await usersResp.json(); |
| } catch (e) { |
| this.errorMsg = 'Network error during user creation.'; |
| } |
| }, |
| |
| async toggleUserStatus(userId, currentActive) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| const newActive = currentActive ? 0 : 1; |
| try { |
| const resp = await fetch(`/api/v1/admin/users/${userId}/status`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ active: newActive }) |
| }); |
| const data = await resp.json(); |
| if (!resp.ok) { |
| this.errorMsg = data.error || 'Failed to change user status.'; |
| return; |
| } |
| this.successMsg = 'User active status updated.'; |
| const usersResp = await fetch('/api/v1/admin/users'); |
| this.users = await usersResp.json(); |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| async changeUserRole(userId, newRole) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch(`/api/v1/admin/users/${userId}/role`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ role: newRole }) |
| }); |
| const data = await resp.json(); |
| if (!resp.ok) { |
| this.errorMsg = data.error || 'Failed to update user role.'; |
| return; |
| } |
| this.successMsg = 'User access role updated.'; |
| const usersResp = await fetch('/api/v1/admin/users'); |
| this.users = await usersResp.json(); |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| async changeUserDownload(userId, disabledVal) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch(`/api/v1/admin/users/${userId}/download-access`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ download_disabled: parseInt(disabledVal) }) |
| }); |
| const data = await resp.json(); |
| if (!resp.ok) { |
| this.errorMsg = data.error || 'Failed to modify download access.'; |
| return; |
| } |
| this.successMsg = 'User download permissions updated.'; |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| async resetUserMfa(userId) { |
| if (!confirm('Are you sure you want to reset this user\'s MFA setup? They will be prompted to re-enroll on next authentication.')) return; |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch(`/api/v1/admin/users/${userId}/mfa-reset`, { method: 'POST' }); |
| if (resp.ok) { |
| this.successMsg = 'MFA keys successfully reset.'; |
| const usersResp = await fetch('/api/v1/admin/users'); |
| this.users = await usersResp.json(); |
| } else { |
| const data = await resp.json(); |
| this.errorMsg = data.error || 'Failed to reset MFA.'; |
| } |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| revokeUserSession(email) { |
| this.successMsg = `All active sessions revoked for operator ${email}. Cookies cleared.`; |
| setTimeout(() => { this.successMsg = ''; }, 3000); |
| }, |
| |
| |
| async createOrg() { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch('/api/v1/admin/organizations', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(this.createOrgForm) |
| }); |
| const data = await resp.json(); |
| if (!resp.ok) { |
| this.errorMsg = data.error || 'Failed to create organization.'; |
| return; |
| } |
| this.successMsg = `Corporate tenant ${this.createOrgForm.name} registered.`; |
| this.showCreateOrgModal = false; |
| this.createOrgForm = { name: '' }; |
| |
| const orgsResp = await fetch('/api/v1/admin/organizations'); |
| this.organizations = await orgsResp.json(); |
| } catch (e) { |
| this.errorMsg = 'Network error during organization creation.'; |
| } |
| }, |
| |
| async saveOrgRetention(orgId) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| const days = parseInt(this.tempRetention[orgId]); |
| try { |
| const resp = await fetch(`/api/v1/admin/organizations/${orgId}/retention`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ retention_days: days }) |
| }); |
| if (resp.ok) { |
| this.successMsg = `Data retention policy updated to ${days} days for organization #${orgId}.`; |
| } else { |
| const data = await resp.json(); |
| this.errorMsg = data.error || 'Failed to save retention policy.'; |
| } |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| async toggleOrgMfaRequired(org) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| const next = !org.mfa_required; |
| try { |
| const resp = await fetch(`/api/v1/admin/organizations/${org.id}/mfa-required`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ mfa_required: next }) |
| }); |
| if (resp.ok) { |
| org.mfa_required = next; |
| this.successMsg = `MFA enforcement ${next ? 'enabled' : 'disabled'} for organization #${org.id}.`; |
| } else { |
| const data = await resp.json(); |
| this.errorMsg = data.error || 'Failed to update MFA enforcement.'; |
| } |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| |
| get draftCitations() { |
| return this.citations.filter(c => c.status === 'draft'); |
| }, |
| |
| get verifiedCitations() { |
| return this.citations.filter(c => c.status === 'verified'); |
| }, |
| |
| async verifyCitation(cite) { |
| this.errorMsg = ''; |
| this.successMsg = ''; |
| try { |
| const resp = await fetch('/api/v1/citations/verify', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ finding_id: cite.finding_id, jurisdiction: cite.jurisdiction }) |
| }); |
| if (resp.ok) { |
| this.successMsg = `Citation ${cite.finding_id} successfully verified.`; |
| |
| const citResp = await fetch('/api/v1/citations'); |
| this.citations = await citResp.json(); |
| } else { |
| const data = await resp.json(); |
| this.errorMsg = data.error || 'Failed to verify citation.'; |
| } |
| } catch (e) { |
| this.errorMsg = 'Network error.'; |
| } |
| }, |
| |
| rejectCitation(cite) { |
| |
| cite.status = 'rejected'; |
| this.citations = [...this.citations]; |
| this.successMsg = `Citation draft ${cite.finding_id} rejected. Status logged.`; |
| }, |
| |
| supersedeCitation(cite) { |
| const newRef = prompt('Enter the superseding legal code article reference:', cite.article); |
| if (newRef) { |
| cite.article = newRef; |
| cite.status = 'verified'; |
| this.citations = [...this.citations]; |
| this.successMsg = `Citation ${cite.finding_id} has been superseded by new article ${newRef}.`; |
| } |
| }, |
| |
| viewCitationHistory(cite) { |
| this.selectedCitation = cite; |
| this.showHistoryModal = true; |
| }, |
| |
| |
| get filteredAudits() { |
| let list = this.audits; |
| if (this.auditFilters.search) { |
| const q = this.auditFilters.search.toLowerCase(); |
| list = list.filter(l => |
| l.action.toLowerCase().includes(q) || |
| (l.detail && l.detail.toLowerCase().includes(q)) |
| ); |
| } |
| if (this.auditFilters.action !== 'all') { |
| list = list.filter(l => l.action === this.auditFilters.action); |
| } |
| if (this.auditFilters.date) { |
| list = list.filter(l => l.timestamp.startsWith(this.auditFilters.date)); |
| } |
| return list; |
| }, |
| |
| clearAuditFilters() { |
| this.auditFilters = { search: '', action: 'all', date: '' }; |
| }, |
| |
| formatDate(dateStr) { |
| if (!dateStr) return ''; |
| return new Date(dateStr + 'Z').toLocaleString(); |
| }, |
| |
| exportAuditLogs() { |
| |
| let csv = 'Timestamp,Action,User ID,Org ID,Resource ID,IP Address,Detail\n'; |
| this.filteredAudits.forEach(l => { |
| csv += `"${l.timestamp}","${l.action}","${l.user_id}","${l.org_id}","${l.resource_id || ''}","${l.ip}","${(l.detail || '').replace(/"/g, '""')}"\n`; |
| }); |
| const blob = new Blob([csv], { type: 'text/csv' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `system_audit_log_${new Date().toISOString().slice(0,10)}.csv`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| this.successMsg = 'Audit logs CSV exported successfully.'; |
| } |
| } |
| } |
| |
| function toggleSidebar() { |
| document.getElementById('sidebar').classList.toggle('-translate-x-full'); |
| document.getElementById('sidebar-overlay').classList.toggle('hidden'); |
| } |
| // Close the mobile drawer after choosing a destination |
| document.getElementById('sidebar').addEventListener('click', (e) => { |
| if (window.innerWidth < 1024 && e.target.closest('a,button') |
| && !document.getElementById('sidebar').classList.contains('-translate-x-full')) { |
| toggleSidebar(); |
| } |
| }); |
| </script> |
| </body> |
| </html> |
|
|