Help
RSS
API
Feed
Maltego
Contact
Domain > aadcdn.microsoftonlne.fr
×
More information on this domain is in
AlienVault OTX
Is this malicious?
Yes
No
DNS Resolutions
Date
IP Address
2025-10-20
104.21.86.66
(
ClassC
)
Port 80
HTTP/1.1 200 OKDate: Mon, 20 Oct 2025 20:42:43 GMTContent-Type: text/htmlTransfer-Encoding: chunkedConnection: keep-aliveServer: cloudflareLast-Modified: Mon, 20 Oct 2025 18:10:31 GMTNel: {report_to:cf-nel,success_fraction:0.0,max_age:604800}vary: accept-encodingReport-To: {group:cf-nel,max_age:604800,endpoints:{url:https://a.nel.cloudflare.com/report/v4?s6HN9Uy4Q9HuJIMFReA3OjZPBm4Y%2BiIlsWv09CipaDaJ2Ug90iVYOfYYTDexUNgZNvcjF6XHz8CjcaZClRPM4wMYnyeoMgokngnp57cnSrOfyrouCt7rZdA%3D%3D}}cf-cache-status: DYNAMICCF-RAY: 991b57e62fb86c17-PDXalt-svc: h3:443; ma86400 !DOCTYPE html>html>head>title>Welcome to nginx!/title>style>html { color-scheme: light dark; }body { width: 35em; margin: 0 auto;font-family: Tahoma, Verdana, Arial, sans-serif; }/style>/head>body>h1>Welcome to nginx!/h1>p>If you see this page, the nginx web server is successfully installed andworking. Further configuration is required./p>p>For online documentation and support please refer toa hrefhttp://nginx.org/>nginx.org/a>.br/>Commercial support is available ata hrefhttp://nginx.com/>nginx.com/a>./p>p>em>Thank you for using nginx./em>/p>/body>/html>
Port 443
HTTP/1.1 200 OKDate: Mon, 20 Oct 2025 20:42:44 GMTContent-Type: text/html; charsetutf-8Transfer-Encoding: chunkedConnection: keep-aliveServer: cloudflareNel: {report_to:cf-nel,success_fraction:0.0,max_age:604800}last-modified: Mon, 20 Oct 2025 17:05:28 GMTvary: accept-encodingx-content-type-options: nosniffX-Content-Type-Options: nosniffreferrer-policy: strict-origin-when-cross-originReferrer-Policy: strict-origin-when-cross-originpermissions-policy: geolocation(), microphone(), camera()x-frame-options: SAMEORIGINX-Frame-Options: SAMEORIGINStrict-Transport-Security: max-age31536000; includeSubDomainsX-XSS-Protection: 1; modeblockReport-To: {group:cf-nel,max_age:604800,endpoints:{url:https://a.nel.cloudflare.com/report/v4?solVcqRBP1fWRYK8nshQMRtseKo4BhvzIh6R%2FiaLsHQFCdEtDJ9c1W3MlcQvO7%2FYfwjZvGUEQEWwAwiyvzhnxbH%2Bbm2oczbNg4mo6N%2FwIti%2BUCoQ%2FR4M%3D}}cf-cache-status: DYNAMICCF-RAY: 991b57e8aeabfef7-PDXalt-svc: h3:443; ma86400 !DOCTYPE html>html langpt>head> meta charsetutf-8> meta nameviewport contentwidthdevice-width, initial-scale1> title>Caravela HUB/title> link relicon typeimage/png sizes32x32 href/static/assets/favicon-32x32.png> link relicon typeimage/png sizes16x16 href/static/assets/favicon-16x16.png> link relapple-touch-icon href/static/assets/apple-touch-icon.png> link relmanifest href/static/assets/site.webmanifest> link relstylesheet hrefhttps://fonts.googleapis.com/css2?familyInter:wght@400;500;600;700&displayswap> link relstylesheet href/static/assets/styles.patch.css> style> :root { color-scheme: dark; --bg: #0f172a; --bg-strong: #1e293b; --bg-card: rgba(15, 23, 42, 0.78); --bg-card-soft: rgba(30, 41, 59, 0.6); --accent: #58e6ff; /* mapped to theme cyan */ --accent-strong: #9feeff; --danger: #f87171; --success: #34d399; --warning: #fbbf24; --text: #e2e8f0; --text-muted: #94a3b8; --border: rgba(148, 163, 184, 0.28); } .icon-btn:hover { background: rgba(15, 23, 42, 0.85); color: var(--text); border-color: rgba(88, 230, 255, 0.45); box-shadow: 0 10px 24px rgba(88, 230, 255, 0.18); transform: translateY(-2px); } .icon-btn.active { background: rgba(52, 211, 153, 0.18); color: var(--success); border-color: rgba(52, 211, 153, 0.55); } .auto-refresh-btn.active { background: var(--success); color: #0f172a; border-color: var(--success); box-shadow: 0 12px 26px rgba(52, 211, 153, 0.35); } .icon-btn.danger { color: var(--danger); border-color: rgba(248, 113, 113, 0.35); } .icon-btn.danger:hover { background: rgba(248, 113, 113, 0.22); color: var(--danger); border-color: rgba(248, 113, 113, 0.55); box-shadow: 0 12px 26px rgba(248, 113, 113, 0.25); transform: translateY(-2px); } .nav-btn { border-radius: 999px; padding: 0.6rem 1.35rem; background: rgba(15, 23, 42, 0.45); border: 1px solid rgba(148, 163, 184, 0.28); color: var(--text-muted); font-weight: 600; letter-spacing: 0.03em; backdrop-filter: blur(12px); transition: background 0.2s ease, color 0.2s ease, border 0.2s ease, transform 0.2s ease; } .nav-btn.active { background: linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(251, 146, 60, 0.3)); border-color: rgba(251, 146, 60, 0.55); color: #fefce8; box-shadow: 0 12px 28px rgba(249, 115, 22, 0.28); } .nav-btn:hover { transform: translateY(-1px); border-color: rgba(148, 163, 184, 0.45); color: var(--text); background: rgba(15, 23, 42, 0.6); } /* Disabled/locked nav appearance */ .nav-btn.locked { position: relative; opacity: 0.6; filter: grayscale(0.3); cursor: not-allowed; text-decoration: none; } .nav-btn.locked::after { content: ; position: absolute; left: 10%; right: 10%; top: 50%; height: 2px; background: rgba(248, 113, 113, 0.8); /* red strike */ transform: rotate(-8deg); border-radius: 2px; pointer-events: none; } .tab-content { display: none; } .tab-content.active { display: block; } /* Force-hide restricted content when aria-hidden is set */ .tab-contentaria-hiddentrue { display: none !important; } main { flex: 1; padding: 0 5vw 4.5rem; position: relative; z-index: 1; } .page-container { max-width: 1200px; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 2.5rem; padding-top: 1.5rem; } .grid { display: grid; gap: 1.75rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } #users-container.grid { grid-template-columns: repeat(auto-fit, minmax(240px, 300px)); justify-content: center; justify-items: stretch; gap: 1rem; } .section-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 2rem; } .section-header h2 { margin: 0; color: var(--accent); } .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 1.9rem; box-shadow: var(--shadow); backdrop-filter: blur(16px); transition: transform 0.25s ease, box-shadow 0.25s ease, border 0.25s ease; } .card:hover { transform: translateY(-6px) scale(1.01); border-color: rgba(88, 230, 255, 0.35); box-shadow: 0 18px 38px rgba(88, 230, 255, 0.18); } .card h2 { margin: 0 0 1.2rem; font-size: 1.25rem; font-weight: 600; display: flex; align-items: center; gap: 0.6rem; color: var(--accent); } .table-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; } .table-title { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .table-controls { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .icon-control { width: 38px; height: 38px; border-radius: 10px; border: 1px solid rgba(148, 163, 184, 0.35); background: rgba(15, 23, 42, 0.55); color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; font-size: 1.1rem; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, border 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .icon-control:hover { background: rgba(15, 23, 42, 0.85); color: var(--text); border-color: rgba(88, 230, 255, 0.45); box-shadow: 0 10px 24px rgba(88, 230, 255, 0.2); transform: translateY(-2px); } /* Topbar controls placed between brand and menu */ .topbar-controls { display: flex; align-items: center; gap: .5rem; background: rgba(8, 20, 34, 0.55); border: 1px solid rgba(88, 230, 255, 0.18); border-radius: 12px; padding: 6px 8px; backdrop-filter: blur(6px); } /* Header below a new fixed topbar; no background on brand row */ header { padding-top: 0; position: relative; /* not sticky; fixed bar will be */ z-index: 50; background: transparent; backdrop-filter: none; border-bottom: none; } .top-bar { display: flex; justify-content: center; align-items: center; padding: 0.5rem 1rem 0.25rem; background: transparent !important; /* ensure no background on brand strip */ } .top-bar .brand { display: flex; align-items: center; justify-content: center; } .top-bar .brand img { width: 88px; height: 88px; object-fit: contain; filter: drop-shadow(0 6px 16px rgba(88, 230, 255, 0.16)); } .page-topbar { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; border:0px !important; } .main-nav { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; padding: 0.5rem 1rem 1rem; } /* New fixed topbar with very subtle bottom-fade gradient */ .fixed-topbar { position: fixed; top: 0; left: 0; right: 0; height: 56px; z-index: 120; display: flex; align-items: center; justify-content: center; background: linear-gradient(180deg, rgba(15,23,42,0.82), rgba(15,23,42,0)); pointer-events: auto; } .fixed-topbar .topbar-controls { display: flex; align-items: center; justify-content: center; gap: .5rem; background: transparent; border: none; border-radius: 0; padding: 0; backdrop-filter: none; } .fixed-topbar .icon-btn { width: 36px; height: 36px; font-size: 1rem; } /* Offset the content below the fixed topbar */ .app .fixed-topbar + header { margin-top: 56px; } /* Topbar controls below the navigation, discreet styling */ .page-topbar { position: relative; } .main-nav { position: relative; } .topbar-controls { position: static; z-index: auto; margin-top: .35rem; opacity: 0.95; background: rgba(8, 20, 34, 0.35); border: 1px solid rgba(88, 230, 255, 0.12); padding: 4px 6px; } .topbar-controls .icon-btn { width: 32px; height: 32px; font-size: .95rem; } .pill-btn, .primary-pill { border-radius: 999px; padding: 0.7rem 1.2rem; font-size: 0.85rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border 0.2s ease; } .pill-btn { background: rgba(148, 163, 184, 0.18); color: var(--text); border: 1px solid rgba(148, 163, 184, 0.26); } .pill-btn:hover { background: rgba(148, 163, 184, 0.28); border: 1px solid rgba(88, 230, 255, 0.35); box-shadow: 0 12px 26px rgba(88, 230, 255, 0.16); transform: translateY(-2px); } .primary-pill { background: var(--accent); color: #0f172a; /* remove orange glow */ box-shadow: 0 12px 24px rgba(88, 230, 255, 0.18); border-width: medium; border-style: solid; border-color: rgba(88, 230, 255, 0.25); } .primary-pill:hover { transform: translateY(-2px); /* cyan hover instead of orange */ box-shadow: 0 16px 34px rgba(88, 230, 255, 0.25); border-width: medium; border-style: solid; border-color: rgba(88, 230, 255, 0.55); } form .field { display: flex; flex-direction: column; gap: 0.45rem; margin-bottom: 1.1rem; } label { font-weight: 500; font-size: 0.9rem; color: var(--text-muted); } inputtypetext, inputtypeemail, inputtypepassword, inputtypenumber, select { appearance: none; border: 1px solid var(--border); border-radius: 12px; background: rgba(15, 23, 42, 0.85); color: var(--text); padding: 0.75rem 0.9rem; font-size: 0.95rem; transition: border 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } input:focus, select:focus { outline: none; border-color: rgba(88, 230, 255, 0.6); box-shadow: 0 0 0 3px rgba(88, 230, 255, 0.18); background: rgba(15, 23, 42, 0.95); } select { background-image: linear-gradient(45deg, transparent 50%, var(--text) 50%), linear-gradient(135deg, var(--text) 50%, transparent 50%); background-position: calc(100% - 18px) calc(50% - 3px), calc(100% - 13px) calc(50% - 3px); background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; } select option { background-color: var(--bg-strong); color: var(--text); } table { width: 100%; border-collapse: collapse; } thead th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); padding: 0.75rem 0.75rem 0.6rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.18); } thead th.sortable { cursor: pointer; position: relative; padding-right: 1.4rem; } thead th.sortable::after { content: ; position: absolute; top: 50%; right: 0.6rem; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid var(--text-muted); transform: translateY(-35%); opacity: 0.5; } thead th.sortable.sorted-asc::after { border-top: none; border-bottom: 6px solid var(--accent-strong); transform: translateY(-25%); opacity: 1; } thead th.sortable.sorted-desc::after { border-top: 6px solid var(--accent-strong); opacity: 1; } tbody td { padding: 1rem 0.75rem 1rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); font-size: 0.95rem; vertical-align: top; } tbody tr:last-child td { border-bottom: none; } tbody tr.is-blocked { background: rgba(248, 113, 113, 0.08); } .domain-link { color: var(--text); font-weight: 600; text-decoration: none; } .domain-link:hover { color: var(--accent-strong); text-decoration: underline; } .domain-cell { display: grid; gap: 0.5rem; } .domain-top { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } .type-pill { padding: 0.25rem 0.65rem; border-radius: 999px; font-size: 0.72rem; letter-spacing: 0.04em; background: rgba(148, 163, 184, 0.18); color: var(--text-muted); text-transform: uppercase; } .status-row { display: flex; flex-wrap: wrap; gap: 0.4rem; } .blocked-note { color: var(--danger); font-size: 0.8rem; } .info-stack { display: grid; gap: 0.4rem; } .info-line { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; font-size: 0.85rem; } .info-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } .info-value { font-weight: 600; color: var(--text); text-align: right; overflow-wrap: anywhere; } .info-value.warning { color: var(--warning); } .info-value.danger { color: var(--danger); } .status-chip.subtle { background: rgba(148, 163, 184, 0.08); color: var(--text-muted); } .ssl-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.78rem; } .status-active { color: var(--success); } .status-pending { color: var(--warning); } .status-expired, .status-falhou, .status-failed { color: var(--danger); } .status-desconhecido { color: var(--text-muted); } .status-chip { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.38rem 0.8rem; border-radius: 999px; font-size: 0.78rem; font-weight: 600; background: rgba(148, 163, 184, 0.12); } .status-chip.blocked { color: var(--danger); background: rgba(248, 113, 113, 0.15); } .status-chip.active { color: var(--success); background: rgba(52, 211, 153, 0.15); } .cloud-indicator { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; } .cloud-indicator .dot { width: 12px; height: 12px; border-radius: 50%; background: #64748b; box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.2); } .cloud-indicator.active .dot { background: var(--accent); box-shadow: 0 0 0 3px rgba(88, 230, 255, 0.3); } .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; } .actions button { padding: 0.45rem 0.95rem; border-radius: 12px; background: rgba(88, 230, 255, 0.12); color: var(--text); font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(88, 230, 255, 0.25); } .actions button:hover:not(:disabled) { background: rgba(88, 230, 255, 0.22); border-color: rgba(88, 230, 255, 0.45); } .actions .danger { color: var(--danger); background: rgba(248, 113, 113, 0.15); } .empty { text-align: center; padding: 3rem 1rem; color: var(--text-muted); border: 1px dashed rgba(148, 163, 184, 0.3); border-radius: 16px; margin-top: 1.7rem; } footer { text-align: center; padding: 2rem 1rem 2.5rem; color: rgba(226, 232, 240, 0.7); font-size: 0.9rem; backdrop-filter: blur(8px); } .login-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, 1); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(6px); transition: opacity 0.3s ease; pointer-events: auto; } /* Prevent login overlay blink during boot/auth check */ body.booting .login-modal { display: none; } .login-card { width: min(400px, 92vw); background: rgba(15, 23, 42, 0.82); border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 16px; padding: 2.2rem 2.4rem 2.4rem; display: flex; flex-direction: column; gap: 1.2rem; min-width: 0; text-align: center; box-shadow: 0 32px 48px rgba(15, 23, 42, 0.45); } .login-card h2 { margin: 0; font-size: 1.25rem; color: var(--accent); } .login-logo { width: 136px; height: 136px; align-self: center; } .login-slogan { margin: 0; font-size: 0.95rem; color: var(--text-muted); } #login-form .field { text-align: left; } #login-totp-field input { letter-spacing: 0.3rem; text-align: center; } .login-modal.hidden { display: none; } .toast { position: fixed; bottom: 26px; right: 26px; background: rgba(15, 23, 42, 0.95); color: var(--text); padding: 0.9rem 1.3rem; border-radius: 12px; border: 1px solid rgba(148, 163, 184, 0.35); box-shadow: var(--shadow); opacity: 0; transform: translateY(20px); transition: opacity 0.3s ease, transform 0.3s ease; pointer-events: none; z-index: 250; } .toast.show { opacity: 1; transform: translateY(0); } .loading { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; backdrop-filter: blur(4px); background: rgba(15, 23, 42, 0.4); z-index: 220; } .loading.show { display: flex; } .loader { width: 48px; height: 48px; border-radius: 50%; border: 4px solid rgba(148, 163, 184, 0.25); border-top-color: var(--accent); animation: spin 0.9s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .user-card { display: none; width: 100%; max-width: 300px; margin: 0 auto; transition: box-shadow 0.2s, border 0.2s; } .user-card.visible { display: block; } .user-card.hoverable:hover { /* Remove lift to avoid overlap */ transform: none; border-color: rgba(88, 230, 255, 0.35); box-shadow: 0 8px 16px rgba(88, 230, 255, 0.14); position: static; z-index: auto; } /* Make user cards more compact and override generic card hover scaling */ .card.user-card { padding: 1rem; border-radius: 14px; } .card.user-card:hover { transform: none; } .user-card-actions { display: flex; gap: 0.6rem; margin-top: 1rem; } .user-card-actions .secondary-btn { flex: 1; justify-content: center; } .user-card-actions .danger { flex: 1; justify-content: center; background: rgba(248, 113, 113, 0.18); color: var(--danger); } .user-card-actions .danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.28); color: var(--danger); } .user-create-grid { margin-bottom: 1.4rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; align-items: end; } .full-width-btn { grid-column: 1 / -1; justify-self: end; width: auto; } .switch-field { display: flex; align-items: center; gap: 0.75rem; } .switch-field inputtypecheckbox { width: 20px; height: 20px; accent-color: var(--accent); } .user-form-layout { padding: 0 2rem 2rem; display: flex; flex-direction: column; gap: 1.75rem; overflow: hidden; } .user-form-columns { display: grid; /* Use grid for better column control */ grid-template-columns: 1fr 1fr; /* Two equal columns */ gap: 1.5rem; align-items: start; /* Align items to the start of their grid area */ } .user-form-fields { display: flex; /* Keep flex for internal field layout */ flex-direction: column; gap: 1.1rem; /* Consistent gap between fields */ } .permission-panel { flex: 1 1 260px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 16px; padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.08); min-height: 100%; overflow: hidden; } .permission-panel-header h3 { margin: 0; font-size: 1.05rem; color: var(--accent); } .permission-hint { margin: 0; font-size: 0.85rem; color: var(--text-muted); } .permission-panel .permission-badges { margin-top: 0; flex: 1 1 auto; width: 100%; max-height: 320px; overflow-y: auto; } .form-actions { display: flex; gap: 0.75rem; justify-content: center } .form-actions button { flex: 0 0 auto; min-width: 150px; } .placeholder-card { text-align: center; padding: 3rem 1.5rem; color: var(--text-muted); } .help-card { display: flex; flex-direction: column; gap: 1.4rem; } .help-intro { margin: 0; color: var(--text-muted); font-size: 0.92rem; } .help-grid { display: grid; gap: 1.2rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .help-block { background: rgba(15, 23, 42, 0.55); border: 1px solid rgba(148, 163, 184, 0.22); border-radius: 14px; padding: 1.1rem 1.3rem; display: flex; flex-direction: column; gap: 0.65rem; box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.05); } .help-block h3 { margin: 0; font-size: 1rem; color: var(--accent-strong); } .help-block ul { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.45rem; color: var(--text); font-size: 0.9rem; } .help-block a { color: var(--accent-strong); text-decoration: none; } .help-block a:hover { text-decoration: underline; } .tutorial-block { display: flex; flex-direction: column; gap: 0.75rem; color: var(--text); font-size: 0.9rem; } .tutorial-hint { margin: 0; color: var(--text-muted); font-size: 0.85rem; } .tutorial-block ol { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.35rem; } .tutorial-modal-demo { border: 1px solid rgba(249, 115, 22, 0.35); background: rgba(15, 23, 42, 0.7); border-radius: 12px; padding: 0.9rem 1rem; display: grid; gap: 0.75rem; } .tutorial-modal-demo .demo-header { font-weight: 600; color: var(--accent-strong); } .tutorial-modal-demo .demo-body { display: flex; align-items: center; gap: 1rem; } .tutorial-modal-demo .demo-qr { width: 64px; height: 64px; border-radius: 8px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(249, 115, 22, 0.25)); border: 1px dashed rgba(148, 163, 184, 0.5); } .tutorial-modal-demo .demo-text { display: grid; gap: 0.25rem; } .tutorial-modal-demo .demo-line { margin: 0; font-weight: 600; } .tutorial-modal-demo .demo-hint { margin: 0; color: var(--text-muted); font-size: 0.85rem; } .icon-control.dangerous { border-color: rgba(248, 113, 113, 0.45); position: relative; } .icon-control.dangerous::after { content: CUIDADO; /* Add text warning */ position: absolute; top: -28px; /* Adjust position to be above the button */ left: 50%; transform: translateX(-50%); white-space: nowrap; padding: 4px 8px; border-radius: 6px; background: var(--danger); color: var(--text); font-size: 0.7rem; font-weight: 600; box-shadow: 0 4px 12px rgba(248, 113, 113, 0.35); opacity: 0; pointer-events: none; transition: opacity 0.2s ease, transform 0.2s ease; } .icon-control.dangerous:hover::after { opacity: 1; transform: translateX(-50%) translateY(-4px); /* Slight lift on hover */ } #tutorial-overlay { display: none; position: fixed; inset: 0; background: rgba(9, 13, 28, 0.55); z-index: 10000; pointer-events: none; transition: opacity 0.3s ease; opacity: 0; } body.tutorial-mode #tutorial-overlay { display: block; opacity: 1; } #tutorial-spotlight { position: absolute; border: 3px solid var(--accent); border-radius: 12px; box-shadow: 0 0 0 9999px rgba(9, 13, 28, 0.75); pointer-events: auto; transition: all 0.32s cubic-bezier(0.4, 0, 0.2, 1); } .tutorial-tooltip { display: none; position: fixed; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; max-width: 340px; z-index: 10001; box-shadow: var(--shadow); } body.tutorial-mode .tutorial-tooltip { display: block; } .tutorial-step-title { margin: 0 0 1rem 0; color: var(--accent); font-size: 1.1rem; } .tutorial-step-content { margin: 0 0 1.5rem 0; line-height: 1.6; color: var(--text-muted); } .tutorial-avatar-container { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; } .tutorial-avatar { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid var(--accent); flex-shrink: 0; } .tutorial-speech-bubble { position: relative; background: var(--bg-strong); border: 1px solid var(--border); border-radius: 12px; padding: 1rem; flex-grow: 1; color: var(--text); font-size: 0.9rem; line-height: 1.5; } .tutorial-speech-bubble::before { content: ; position: absolute; left: -10px; top: 15px; width: 0; height: 0; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 10px solid var(--border); } .tutorial-speech-bubble::after { content: ; position: absolute; left: -8px; top: 16px; width: 0; height: 0; border-top: 9px solid transparent; border-bottom: 9px solid transparent; border-right: 9px solid var(--bg-strong); } .tutorial-clone-wrapper { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(1.05); z-index: 10002; pointer-events: none; background: rgba(15, 23, 42, 0.88); border: 1px solid rgba(249, 115, 22, 0.35); border-radius: 16px; box-shadow: 0 20px 44px rgba(249, 115, 22, 0.25); padding: 1rem; max-width: 90vw; max-height: 80vh; overflow: auto; } .tutorial-clone { pointer-events: none; } .tutorial-select { width: min(520px, 90vw); } .tutorial-select-subtitle { margin: 0; color: var(--text-muted); font-size: 0.9rem; } .tutorial-select-grid { display: grid; gap: 0.8rem; margin-top: 1.4rem; } .tutorial-option { border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.28); background: rgba(15, 23, 42, 0.55); color: var(--text); padding: 0.6rem 1.4rem; font-size: 0.94rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer; display: inline-flex; justify-content: center; transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease, background 0.2s ease; } .tutorial-option:hover { border-color: rgba(249, 115, 22, 0.45); background: rgba(249, 115, 22, 0.18); box-shadow: 0 12px 28px rgba(249, 115, 22, 0.18); transform: translateY(-2px); } .tutorial-select-actions { display: flex; justify-content: flex-end; margin-top: 1.6rem; } .badge { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.75rem; border-radius: 999px; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; background: rgba(148, 163, 184, 0.1); color: var(--text-muted); } .permission-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 1rem; background: rgba(148, 163, 184, 0.08); border-radius: 12px; margin-top: 0.8rem; min-height: 40px; align-items: center; } .permission-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.8rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; background: rgba(52, 211, 153, 0.2); color: var(--success); border: 1px solid rgba(52, 211, 153, 0.4); transition: background 0.2s ease, border 0.2s ease; } .permission-badge.inactive { background: rgba(248, 113, 113, 0.15); color: var(--danger); border-color: rgba(248, 113, 113, 0.3); } .permission-badge button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; font-size: 1rem; line-height: 1; transition: transform 0.15s ease; display: flex; align-items: center; justify-content: center; } .permission-badge button:hover { transform: scale(1.2); } table .permission-badge, table .permission-badge button { all: revert; display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.6rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; background: rgba(52, 211, 153, 0.2); color: var(--success); border: 1px solid rgba(52, 211, 153, 0.4); cursor: pointer; transition: background 0.2s ease, border 0.2s ease; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; } table .permission-badge:hover, table .permission-badge:focus { background: rgba(52, 211, 153, 0.3); } table .permission-badge.inactive { background: rgba(248, 113, 113, 0.15); color: var(--danger); border-color: rgba(248, 113, 113, 0.3); } table .permission-badge.inactive:hover, table .permission-badge.inactive:focus { background: rgba(248, 113, 113, 0.25); } table .permission-badge button { background: none; border: none; color: inherit; padding: 0; margin: 0; line-height: 1; cursor: pointer; } table .permission-badge button:hover { transform: scale(1.3); } .modal-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.85); display: none; align-items: center; justify-content: center; z-index: 400; backdrop-filter: blur(6px); transition: opacity 0.3s ease; padding: 1rem; } .modal-overlay.show { display: flex; } .modal-content { position: relative; background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 2rem; box-shadow: var(--shadow); max-height: 85vh; overflow-y: auto; width: min(500px, 90vw); } #user-modal, #proxy-modal { position: static; border: 1px solid var(--border); border-radius: 20px; box-shadow: var(--shadow); } #user-modal-overlay, #proxy-modal-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.85); display: none; align-items: center; justify-content: center; z-index: 400; backdrop-filter: blur(6px); padding: 1rem; } #user-modal-overlay.show, #proxy-modal-overlay.show { display: flex; } .modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; } .modal-header h2 { margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--accent); } .modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1.5rem; line-height: 1; transition: color 0.2s ease; } .modal-close:hover { color: var(--text); } .confirm-list { margin: 0.6rem 0 0; padding-left: 1.2rem; color: var(--text-muted); font-size: 0.85rem; } .confirm-list li + li { margin-top: 0.3rem; } /* Tutorial Styles */ #tutorial-tooltip { animation: slideIn 0.3s ease; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } #tutorial-tooltip button { font-size: 0.85rem; padding: 0.5rem 1rem; border-radius: 8px; } .drawer-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.85); display: none; z-index: 400; backdrop-filter: blur(6px); transition: opacity 0.3s ease; } .drawer-overlay.show { display: block; } .drawer { position: fixed; right: 0; top: 0; bottom: 0; width: min(420px, 90vw); background: var(--bg-card); border-left: 1px solid var(--border); box-shadow: var(--shadow); transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 401; display: flex; flex-direction: column; overflow: hidden; } .drawer.show { transform: translateX(0); } .drawer-header { padding: 1.5rem; border-bottom: 1px solid rgba(148, 163, 184, 0.2); display: flex; align-items: center; justify-content: space-between; } .drawer-header h2 { margin: 0; font-size: 1.25rem; font-weight: 700; color: var(--accent); } .drawer-close { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1.5rem; line-height: 1; transition: color 0.2s ease; } .drawer-close:hover { color: var(--text); } .drawer-body { padding: 1.5rem; overflow-y: auto; flex: 1; } .drawer-body .field { margin-bottom: 1.2rem; } .drawer-body buttontypesubmit { width: 100%; justify-content: center; margin-top: 1rem; } @media (max-width: 900px) { header { padding: 2.2rem 1.5rem 1.2rem; } main { padding: 0 1.5rem 4rem; } .drawer { width: 100%; } .user-form-columns { grid-template-columns: 1fr; /* Single column for small screens */ display: flex; /* Revert to flex for column direction */ flex-direction: column; } .form-actions { flex-direction: column; } .form-actions button { width: 100%; min-width: 0; } } @media (max-width: 768px) { .topbar-inner { flex-direction: column; align-items: stretch; gap: 1rem; } .topbar-controls { justify-content: space-between; position: relative !important; } /* Keep the fixed topbar centered on mobile */ .fixed-topbar .topbar-controls { justify-content: center !important; position: static !important; } } .locked { position: relative; } .locked > * { pointer-events: none; filter: saturate(0.65); } .locked::after { content: Em breve; position: absolute; inset: 0; backdrop-filter: blur(6px); background: rgba(15, 23, 42, 0.65); border: 1px solid rgba(148, 163, 184, 0.35); border-radius: 20px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; font-weight: 600; color: var(--text); text-transform: uppercase; letter-spacing: 0.1em; pointer-events: auto; } /style> link relstylesheet href/static/assets/theme.caravela.css>/head>body classbooting> div classlogin-modal idlogin-modal> div classlogin-card> img src/static/assets/logo-256.png altCaravela logo classlogin-logo draggablefalse> p classlogin-slogan>If one need wings, One need simply make them!/p> h2>Autenticação/h2> form idlogin-form> div classfield> label forlogin-username>Utilizador/label> input idlogin-username typetext autocompleteusername required placeholderO teu utilizador> /div> div classfield> label forlogin-password>Palavra-passe/label> input idlogin-password typepassword autocompletecurrent-password required placeholderA tua palavra-passe> /div> div classfield idlogin-totp-field> label forlogin-totp>Google Authenticator/label> input idlogin-totp typetext inputmodenumeric pattern\d{6} maxlength6 placeholderCódigo 6 dígitos> /div> div classfield switch-field> label forlogin-remember>Memorizar sessão/label> input idlogin-remember typecheckbox> /div> button typesubmit stylewidth:100%;justify-content:center;>Entrar/button> /form> /div> /div> div classapp idapp-root styledisplay:none;> div classfixed-topbar> div classtopbar-controls> div classtopbar-user styledisplay:flex;align-items:center;gap:.5rem;padding:0 .25rem;> span idtopbar-username>—/span> span classtopbar-sep>·/span> span idtopbar-ip>—/span> /div> button typebutton idtotp-btn classicon-btn titleGerir 2FA aria-labelGerir 2FA>🔐/button> button typebutton idtutorial-btn classicon-btn titleIniciar guia aria-labelIniciar guia>❓/button> button typebutton idlogout-btn classicon-btn danger titleTerminar sessão aria-labelTerminar sessão>⎋/button> /div> /div> header> div classtop-bar> div classbrand> img src/static/assets/logo-256.png altCaravela logo draggablefalse> /div> /div> div classpage-topbar> nav classmain-nav> button typebutton classnav-btn active data-tabproxies>Proxies/button> button typebutton classnav-btn data-tabgeradores data-requiresadmin>Geradores/button> button typebutton classnav-btn data-tabmonitor data-requiresadmin>Monitorizador/button> button typebutton classnav-btn data-tabemulators data-requiresadmin>Emuladores/button> button typebutton classnav-btn data-tabusers>Utilizadores/button> /nav> div classtopbar-controls>/div> /div> /header> main> div classpage-container> section idtab-proxies classtab-content active> article classcard table-card> div classtable-heading> div classtable-title> h2>Proxies configuradas/h2> /div> div classtable-controls> button typebutton idcreate-proxy-btn classprimary-pill>+ Criar proxy/button> button typebutton idcf-refresh-btn classicon-control titleVerificar Cloudflare/SSL aria-labelVerificar Cloudflare/SSL>☁/button> button typebutton idauto-refresh-btn classicon-control auto-refresh-btn titleAuto refresh desligado aria-labelAuto refresh desligado>⟳/button> button typebutton idrefresh-btn classicon-control titleAtualizar lista aria-labelAtualizar lista>⟳/button> button typebutton idreload-btn classicon-control dangerous pulse-warning title⚠️ Recarregar Nginx (operação disruptiva) aria-labelRecarregar Nginx styledisplay:none;>↻/button> div classproxy-stats-bar> div idproxy-count classproxy-count normal> span>Proxies:/span> span classvalue>0/0/span> /div> div idproxy-blocked classproxy-blocked-status styledisplay: none> span>🔒/span> span>0 bloqueadas/span> /div> div idproxy-expiring classproxy-warning styledisplay: none> span>⚠️/span> span>SSL a expirar em 10 dias/span> /div> /div> /div> /div> div idproxies-container> div classproxy-search-container> input typetext classproxy-search-input placeholderPesquisar por domínio... idproxy-search-input> select classproxy-user-filter idproxy-user-filter styledisplay: none> option value>Todos os utilizadores/option> /select> /div> /div> /article> /section> section idtab-users classtab-content> article classcard> div classsection-header> h2>Gestão de utilizadores/h2> button typebutton idnew-user-btn classbutton>+ Novo utilizador/button> /div> div idusers-container classgrid>/div> /article> /section> section idtab-geradores classtab-content locked data-requiresadmin> section classgrid> article classcard> h2>📱 Gerar QR Code/h2> p stylecolor: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;>Converte textos, links ou dados em códigos QR./p> form idqrcode-form> div classfield> label forqr-input>Conteúdo/label> input idqr-input namecontent typetext placeholderLink, texto ou dados required> /div> button typesubmit stylewidth:100%;justify-content:center;>Gerar QR Code/button> /form> div idqrcode-output styletext-align: center; margin-top: 1rem; display: none;> div idqrcode-container>/div> button typebutton idqrcode-download classsecondary-btn stylemargin-top: 1rem;>Descarregar QR Code/button> /div> /article> article classcard> h2>🔗 Link Encurtado/h2> p stylecolor: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;>Crie links curtos com proteção por password, expiração e queima./p> form idredirect-form> div classfield> label forredirect-proxy>Proxy de destino/label> select idredirect-proxy nameproxy required> option value disabled selected>Selecione uma proxy/option> /select> /div> div classfield> label forredirect-target>URL de destino/label> input idredirect-target nametarget typetext placeholderex: /painel autocompleteurl required> /div> div classfield> label forredirect-password>Proteção por password (opcional)/label> input idredirect-password namepassword typepassword placeholderDeixar vazio sem proteção autocompletenew-password> /div> div classfield> label forredirect-expiry>Data de expiração (opcional)/label> input idredirect-expiry nameexpiry typedatetime-local> /div> div classfield switch-field> label forredirect-burn>Queimar após 1 acesso/label> input idredirect-burn nameburn_after_use typecheckbox> /div> button typesubmit stylewidth:100%;justify-content:center;>Criar Link/button> /form> /article> article classcard> h2>📺 Códigos MyTvOnline/h2> p stylecolor: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;>Gere codes para MyTvOnline 2 e 3 (1x por 24h por proxy)./p> form idtvonline-form> div classfield> label fortvonline-proxy>Portal para gerar código/label> select idtvonline-proxy nameproxy required> option value disabled selected>Selecione um Portal/option> /select> /div> div classfield> label fortvonline-version>Versão/label> select idtvonline-version nameversion required> option value2>MyTvOnline 2/option> option value3>MyTvOnline 3/option> /select> /div> button typesubmit stylewidth:100%;justify-content:center;>Gerar Código/button> /form> div idtvonline-output styledisplay: none; margin-top: 1rem;> p stylecolor: var(--text-muted); font-size: 0.85rem;>Código:/p> div stylebackground: rgba(15, 23, 42, 0.6); padding: 1rem; border-radius: 12px; border: 1px solid var(--border); word-break: break-all; font-family: monospace; font-size: 0.85rem; idtvonline-code>/div> button typebutton idtvonline-copy classsecondary-btn stylewidth: 100%; margin-top: 1rem; justify-content: center;>Copiar Código/button> /div> /article> article classcard> h2>🖼️ Link para Imagem/h2> p stylecolor: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;>Converte um link em imagem PNG./p> form idlink-to-image-form> div classfield> label forlink-input>Link (URL)/label> input idlink-input namelink typeurl placeholderhttps://exemplo.com required> /div> button typesubmit stylewidth:100%;justify-content:center;>Converter para Imagem/button> /form> div idlink-to-image-output styletext-align: center; margin-top: 1rem; display: none;> p stylecolor: var(--text-muted); font-size: 0.85rem;>Imagem:/p> img idlink-to-image-result src altResultado stylemax-width: 100%; border-radius: 12px; margin: 1rem 0; border: 1px solid var(--border);> button typebutton idlink-to-image-copy classsecondary-btn stylewidth: 100%; justify-content: center;>Copiar Link da Imagem/button> /div> /article> /section> article classcard table-card stylemargin-top: 2rem;> h2>Links criados/h2> div idredirects-container>/div> /article> /section> section idtab-monitor classtab-content locked data-requiresadmin> article classcard placeholder-card> h2>Monitorizador de sistemas/h2> p>Dashboard em tempo real a partir do nosso stack de monitorização./p> /article> /section> section idtab-emulators classtab-content locked data-requiresadmin> section classgrid> article classcard> h2>Novo emulador de User-Agent/h2> form idemulator-form> div classfield> label foremu-name>Nome/label> input idemu-name namename typetext placeholderex: Chrome Mobile Spoof required> /div> div classfield idemu-owner-field styledisplay:none;> label foremu-owner>Atribuir a/label> select idemu-owner nameowner>/select> /div> div classfield> label foremu-domains>Domínios (separados por vírgula)/label> input idemu-domains namedomains typetext placeholderex: exemplo.com, painel.exemplo.com> /div> div classfield> label foremu-match>User-Agent origem/label> input idemu-match nameuser_agent_match typetext placeholderregex ou string> /div> div classfield> label foremu-replace>User-Agent destino/label> input idemu-replace nameuser_agent_replace typetext placeholdervalor a aplicar> /div> div classfield> label foremu-template>Template de protocolo/label> textarea idemu-template nameprotocol_template rows3 placeholderHTTP/2, QUIC, etc>/textarea> /div> div classfield> label foremu-rules>Regras (JSON)/label> textarea idemu-rules namerules rows3 placeholder {pattern: bot, action: firewall} >/textarea> /div> div classfield> label foremu-notes>Notas/label> textarea idemu-notes namenotes rows2>/textarea> /div> div classfield switch-field> label foremu-enabled>Ativo/label> input idemu-enabled nameenabled typecheckbox checked> /div> button typesubmit idemu-submit stylewidth:100%;justify-content:center;>Criar emulador/button> /form> /article> article classcard table-card> div classtable-heading> h2>Emuladores configurados/h2> /div> div idemulator-container>/div> /article> /section> /section> /div> /main> footer> Criado por Caravela TV - If one need wings, One need simply make them! /footer> /div> div classloading idloading> div classloader>/div> /div> div classtoast idtoast>/div> !-- SSL Progress Overlay --> div idssl-progress-overlay styledisplay:none;position:fixed;inset:0;background:rgba(9,13,28,0.55);backdrop-filter:blur(6px);z-index:10050;align-items:center;justify-content:center;> div stylebackground:rgba(15,23,42,0.88);border:1px solid rgba(148,163,184,0.3);border-radius:16px;padding:1.5rem 1.8rem;min-width:min(460px, 92vw);max-width:92vw;text-align:center;box-shadow:0 24px 48px rgba(0,0,0,.35);> div styledisplay:flex;flex-direction:column;gap:1rem;align-items:center;> div classloader stylewidth:56px;height:56px;border-width:5px;border-color:rgba(148,163,184,0.22);border-top-color:var(--accent);>/div> div idssl-progress-title stylefont-weight:700;color:var(--accent);font-size:1.1rem;>A pedir SSL…/div> div idssl-progress-message stylecolor:var(--text);font-size:0.95rem;white-space:pre-line;> A pedir SSL a Lets Encrypt… /div> div idssl-progress-help styledisplay:none;text-align:left;color:var(--text-muted);font-size:0.9rem;background:rgba(148,163,184,0.08);border:1px solid rgba(148,163,184,0.18);padding:0.9rem;border-radius:12px;> div stylefont-weight:600;color:var(--danger);margin-bottom:.4rem;>Falhou. Verifica:/div> ul stylemargin:0;padding-left:1.2rem;display:grid;gap:.25rem;> li>Se o domínio está a apontar para o IP correto./li> li>Se escreveu o domínio sem erros./li> li>Se o domínio está na Cloudflare./li> li>Se o registo DNS está com o proxy desligado (nuvem cinzenta) durante o pedido./li> /ul> /div> button idssl-progress-close classsecondary-btn styledisplay:none;justify-content:center;min-width:160px;>Fechar/button> /div> /div> /div> !-- TOTP Modal (blocking when mandatory) --> div classmodal-overlay idtotp-modal-overlay> div classmodal-content idtotp-modal stylewidth: min(440px, 90vw);> div classmodal-header> h2>Google Authenticator/h2> button typebutton classmodal-close idtotp-modal-close aria-labelFechar titleFechar styledisplay:none;>✕/button> /div> div classmodal-body idtotp-modal-body stylepadding: 0 2rem 2rem; display: flex; flex-direction: column; gap: 1.2rem; text-align: left;> p stylemargin:0;color:var(--text-muted);>Carregando informação de 2FA.../p> /div> /div> /div> !-- User Creation/Editing Modal --> div classmodal-overlay iduser-modal-overlay> div classmodal-content iduser-modal stylewidth: min(520px, 90vw); max-height: 90vh;> div classmodal-header> h2 iduser-modal-title>Novo utilizador/h2> button typebutton classmodal-close iduser-modal-close>✕/button> /div> form iduser-create-form classuser-form-layout styleoverflow-y: auto; padding: 1rem; max-height: calc(90vh - 60px);> div classuser-form-fields> div classfield> label fornew-user>Utilizador/label> input idnew-user nameusername typetext placeholderusername required> /div> div classfield> label fornew-email>Email/label> input idnew-email nameemail typeemail autocompleteemail placeholderemail@example.com required> /div> div classfield> label fornew-password>Palavra-passe/label> input idnew-password namepassword typepassword autocompletenew-password placeholder********> /div> div classfield> label fornew-role>Perfil/label> select idnew-role namerole> option valueuser selected>Utilizador/option> option valuecaptain>Captain/option> option valueadmin>Administrador/option> /select> /div> div classfield> label fornew-limit>Limite de proxys/label> input idnew-limit namemax_proxies typenumber min0 placeholder0 ilimitado> /div> div classfield> button typebutton idopen-permissions classpill-btn stylewidth:100%;justify-content:center;>Permissões/button> /div> div classfield switch-field> label fornew-disabled>Desativado/label> input idnew-disabled namedisabled typecheckbox> /div> /div> div classform-actions stylemargin-top: 1.5rem;> button typesubmit iduser-form-submit>Criar utilizador/button> button typebutton iduser-form-cancel classsecondary-btn>Cancelar/button> /div> /div> /form> /div> /div> !-- Permissions Mini Modal --> div classmodal-overlay idpermissions-modal-overlay> div classmodal-content stylewidth:min(420px,90vw)> div classmodal-header> h2>Permissões/h2> button typebutton classmodal-close idpermissions-modal-close>✕/button> /div> div classmodal-body stylepadding: 0 1.5rem 1.5rem; display:flex; flex-direction:column; gap:.75rem;> div idpermission-menu classpermission-list styledisplay:grid; grid-template-columns: 1fr 1fr; gap:.5rem 1rem;>/div> div styledisplay:flex; gap:.6rem; margin-top: .5rem;> button typebutton idpermissions-apply styleflex:1;justify-content:center;>Aplicar/button> button typebutton idpermissions-cancel classsecondary-btn styleflex:1;justify-content:center;>Cancelar/button> /div> /div> /div> /div> !-- Proxy Creation Modal --> div classmodal-overlay idproxy-modal-overlay> div classmodal-content idproxy-modal stylewidth: min(480px, 90vw);> div classmodal-header> h2>Criar nova proxy/h2> button typebutton classmodal-close idproxy-modal-close>✕/button> /div> form idcreate-form stylepadding: 0 2rem 2rem;> div classfield> label fordomain>Domínio/label> input iddomain namedomain typetext placeholderex: painel.exemplo.com required> /div> div classfield> label forproxy_type>Tipo de proxy/label> select idproxy_type nameproxy_type required> option value disabled selected>Seleciona um template/option> option valuePortal>Portal/option> option valueXC API>XC API/option> option valueApp Android>App Android/option> option valuePainel>Painel/option> /select> /div> div classfield idowner-field styledisplay:none;> label forowner>Atribuir a/label> select idowner nameowner>/select> /div> div idproxy-error styledisplay:none; margin-top:.5rem; font-size:.9rem; color: var(--danger);>/div> div styledisplay: flex; gap: 0.75rem; margin-top: 2rem;> button typesubmit idcreate-btn styleflex: 1; justify-content: center;>Criar Proxy/button> button typebutton idproxy-form-cancel classsecondary-btn styleflex: 1; justify-content: center;>Cancelar/button> /div> /form> /div> /div> !-- Modal for prompts --> div classmodal-overlay idprompt-modal> div classmodal-content> div classmodal-header> h2 idprompt-modal-title>Entrada/h2> button typebutton classmodal-close idprompt-modal-close>✕/button> /div> form idprompt-modal-form stylepadding: 0 2rem 2rem;> div classfield> label forprompt-modal-input idprompt-modal-label>Valor/label> input idprompt-modal-input typetext> /div> div styledisplay: flex; gap: 0.75rem; margin-top: 1.5rem;> button typesubmit styleflex: 1; justify-content: center;>Confirmar/button> button typebutton idprompt-modal-cancel classsecondary-btn styleflex: 1; justify-content: center;>Cancelar/button> /div> /form> /div> /div> !-- Confirmation modal --> div classmodal-overlay idconfirm-modal> div classmodal-content> div classmodal-header> h2 idconfirm-modal-title>Confirmação/h2> button typebutton classmodal-close idconfirm-modal-close>✕/button> /div> p idconfirm-modal-message stylemargin: 1rem 0; color: var(--text-muted);>Tem a certeza?/p> div styledisplay: flex; gap: 0.75rem;> button typebutton idconfirm-modal-ok styleflex: 1; justify-content: center;>Confirmar/button> button typebutton idconfirm-modal-cancel classsecondary-btn styleflex: 1; justify-content: center;>Cancelar/button> /div> /div> /div> !-- Tutorial Overlay --> div idtutorial-overlay> div idtutorial-spotlight>/div> /div> !-- Tutorial Tooltip --> div idtutorial-tooltip classtutorial-tooltip> h3 idtutorial-step-title classtutorial-step-title>Passo/h3> div idtutorial-step-content classtutorial-step-content>Conteúdo/div> div styledisplay: flex; gap: 0.5rem; justify-content: space-between;> button idtutorial-prev styleflex: 1; classsecondary-btn>← Anterior/button> button idtutorial-next styleflex: 1;>Próximo →/button> button idtutorial-skip styleflex: 1; classsecondary-btn>Pular/button> /div> /div> !-- Tutorial Selector --> div classmodal-overlay idtutorial-select-overlay> div classmodal-content tutorial-select> div classmodal-header> h2>Guia interativo/h2> button typebutton classmodal-close idtutorial-select-close>✕/button> /div> p classtutorial-select-subtitle>Escolhe a área que queres explorar./p> div classtutorial-select-grid> button typebutton classtutorial-option data-guideuserbar>Barra do utilizador/button> button typebutton classtutorial-option data-guideproxies>Página de proxies/button> button typebutton classtutorial-option data-guideusers>Gestão de utilizadores/button> /div> div classtutorial-select-actions> button typebutton classsecondary-btn idtutorial-select-cancel>Cancelar/button> /div> /div> /div> script> const AUTO_REFRESH_INTERVAL 60000; // 60s default auto refresh cadence const STORAGE_KEY caravelaAuth; const state { proxies: , emulators: , users: , currentUser: null, loadingCount: 0, sortField: domain, sortDirection: asc, authHeader: null, activeTab: proxies, settings: { auto_refresh_enabled: false }, autoRefreshTimer: null, editingEmulatorId: null, editingUsername: null, fetchWrapped: false, rememberLogin: false, totp: { enabled: false, pending: false, secret: null, otpauth_url: null, blocking: false } }; const MENU_KEYS proxies, iptv, monitor, clientes, support, taberna, emulators ; // Mirror backend default permissions by role function defaultPermissionsForRole(role) { const roleKey (role || user).toLowerCase(); const base Object.fromEntries(MENU_KEYS.map(k > k, false)); if (roleKey admin) { MENU_KEYS.forEach(k > basek true); return base; } if (roleKey captain) { proxies,emulators,support,taberna.forEach(k > basek true); return base; } baseproxies true; return base; } // Filter assignable menu keys for current user (admins see all) function getAssignableMenuKeys() { if (!state.currentUser) return MENU_KEYS.slice(); if (state.currentUser.role admin) return MENU_KEYS.slice(); const myPerms state.currentUser.permissions || {}; return MENU_KEYS.filter(k > !!myPermsk); } const BG_IMAGE_PATH /static/assets/bg.png; (function initBackgroundImage() { const img new Image(); img.onload function() { document.body.classList.add(bg-ready); }; img.onerror function() { document.body.classList.add(bg-ready); }; img.src BG_IMAGE_PATH; })(); function escapeHtml(value) { if (value null || value undefined) return ; return String(value) .replace(/&/g, &) .replace(//g, <) .replace(/>/g, >) .replace(//g, ") .replace(//g, '); } function formatDate(dateStr) { if (!dateStr) return —; const d new Date(dateStr); if (Number.isNaN(d.getTime())) return —; const day String(d.getDate()).padStart(2, 0); const month String(d.getMonth() + 1).padStart(2, 0); return `${day}/${month}/${d.getFullYear()}`; } function formatDateTime(dateStr) { if (!dateStr) return —; const d new Date(dateStr); if (Number.isNaN(d.getTime())) return —; const day String(d.getDate()).padStart(2, 0); const month String(d.getMonth() + 1).padStart(2, 0); const hours String(d.getHours()).padStart(2, 0); const mins String(d.getMinutes()).padStart(2, 0); return `${day}/${month}/${d.getFullYear()} ${hours}:${mins}`; } function setTotpStateFromUser(user) { const enabled !!(user && user.totp_enabled); const pending !!(user && user.totp_pending); state.totp.enabled enabled; state.totp.pending pending; if (!pending) { state.totp.secret null; state.totp.otpauth_url null; } updateTotpButtonState(); } function updateTotpButtonState() { if (!totpBtn) return; if (state.totp.enabled) { totpBtn.classList.add(active); totpBtn.title 2FA ativo; totpBtn.setAttribute(aria-label, 2FA ativo); } else if (state.totp.pending) { totpBtn.classList.add(active); totpBtn.title Configuração 2FA pendente; totpBtn.setAttribute(aria-label, Configuração 2FA pendente); } else { totpBtn.classList.remove(active); totpBtn.title Gerir 2FA; totpBtn.setAttribute(aria-label, Gerir 2FA); } } function openTotpModal(blocking true) { const isBlocking !!blocking; renderTotpModal(); totpModalOverlay.classList.add(show); totpModal.style.display block; // If blocking, hide the close button and block overlay click const closeBtn document.getElementById(totp-modal-close); if (closeBtn) closeBtn.style.display isBlocking ? none : ; totpModalOverlay.dataset.blocking isBlocking ? true : false; } function closeTotpModal(force false) { if (!force && totpModalOverlay && totpModalOverlay.dataset.blocking true) { return; // do not allow closing while blocking } totpModalOverlay.classList.remove(show); totpModal.style.display none; } function renderTotpModal() { if (!totpModalBody) return; let html ; if (state.totp.enabled) { const verifySection state.totp.blocking ? ` div classcard stylebackground:rgba(88,230,255,0.08);border-color:rgba(88,230,255,0.35);margin-bottom:1rem;> p stylemargin:0 0 .6rem;color:var(--text);font-weight:600;>Verificação necessária/p> p stylemargin:0 0 .75rem;color:var(--text-muted);font-size:.95rem;>Introduz o teu código 2FA para continuar./p> div classfield> label fortotp-verify-code>Código 2FA/label> input idtotp-verify-code typetext inputmodenumeric maxlength6 placeholderCódigo 6 dígitos styletext-align:center;letter-spacing:0.3rem;> /div> button typebutton idtotp-verify-btn stylewidth:100%;justify-content:center;>Verificar/button> /div> ` : ; const disableSection ` p stylemargin:0;color:var(--text-muted);>A autenticação em dois fatores está ativa para a sua conta./p> div classfield stylemargin-top:.8rem;> label fortotp-disable-code>Introduz o código atual para desativar/label> input idtotp-disable-code typetext inputmodenumeric maxlength6 placeholderCódigo 6 dígitos styletext-align:center;letter-spacing:0.3rem;> /div> button typebutton idtotp-disable-btn classdanger styleborder:none;border-radius:12px;padding:0.6rem 1.4rem;font-weight:600;background:rgba(248,113,113,0.18);color:var(--danger);>Desativar 2FA/button> `; html verifySection + disableSection; } else if (state.totp.pending && state.totp.secret && state.totp.otpauth_url) { const qrUrl `https://api.qrserver.com/v1/create-qr-code/?size240x240&data${encodeURIComponent(state.totp.otpauth_url)}`; html ` p stylemargin:0;color:var(--text-muted);>Digitaliza o QR code com a app Google Authenticator ou introduz o código manualmente./p> div styledisplay:flex;justify-content:center;> img src${qrUrl} altQR Code stylewidth:200px;height:200px;border-radius:12px;border:1px solid rgba(148,163,184,0.3);> /div> div styletext-align:center;font-family:monospace;font-size:1rem;color:var(--accent);>${state.totp.secret}/div> div classfield> label fortotp-confirm-code>Introduz o código de 6 dígitos/label> input idtotp-confirm-code typetext inputmodenumeric maxlength6 placeholderCódigo 6 dígitos styletext-align:center;letter-spacing:0.3rem;> /div> div styledisplay:flex;gap:0.6rem;> button typebutton idtotp-confirm-btn styleflex:1;justify-content:center;>Confirmar/button> button typebutton idtotp-cancel-btn classsecondary-btn styleflex:1;justify-content:center;>Cancelar/button> /div> `; } else if (state.totp.pending) { html ` p stylemargin:0;color:var(--text-muted);>Existe uma configuração pendente. Gera um novo código para concluir a ativação./p> div styledisplay:flex;gap:0.6rem;> button typebutton idtotp-start-btn styleflex:1;justify-content:center;>Gerar novo código/button> button typebutton idtotp-cancel-btn classsecondary-btn styleflex:1;justify-content:center;>Cancelar/button> /div> `; } else { html ` p stylemargin:0;color:var(--text-muted);>Protege a tua conta com um segundo fator de autenticação compatível com o Google Authenticator./p> button typebutton idtotp-start-btn stylejustify-content:center;>Ativar 2FA/button> `; } totpModalBody.innerHTML html; const startBtn document.getElementById(totp-start-btn); if (startBtn) startBtn.addEventListener(click, startTotpSetup); const confirmBtn document.getElementById(totp-confirm-btn); if (confirmBtn) confirmBtn.addEventListener(click, async () > { const code document.getElementById(totp-confirm-code).value.trim(); if (!code) { showToast(Introduz o código de 6 dígitos., danger); return; } await confirmTotpSetup(code); }); const cancelBtn document.getElementById(totp-cancel-btn); if (cancelBtn) cancelBtn.addEventListener(click, cancelTotpSetup); const disableBtn document.getElementById(totp-disable-btn); if (disableBtn) disableBtn.addEventListener(click, async () > { const code document.getElementById(totp-disable-code).value.trim(); if (!code) { showToast(Introduz o código de 6 dígitos., danger); return; } await disableTotp(code); }); const verifyBtn document.getElementById(totp-verify-btn); if (verifyBtn) verifyBtn.addEventListener(click, async () > { const code document.getElementById(totp-verify-code).value.trim(); if (!code) { showToast(Introduz o código de 6 dígitos., danger); return; } await verifyTotp(code); }); } function canManageProxies() { return state.currentUser && (state.currentUser.role admin || state.currentUser.role captain); } function canManageUsers() { return state.currentUser && (state.currentUser.role admin || state.currentUser.role captain); } function hasPermission(key) { if (!state.currentUser) return false; const perms state.currentUser.permissions || {}; return !!permskey; } function canManageEmulator(item) { if (!state.currentUser || !hasPermission(emulators)) return false; if (canManageProxies()) return true; return item && item.owner state.currentUser.username; } // Modal elements const userModalOverlay document.getElementById(user-modal-overlay); const userModal document.getElementById(user-modal); const proxyModalOverlay document.getElementById(proxy-modal-overlay); const proxyModal document.getElementById(proxy-modal); const promptModal document.getElementById(prompt-modal); const confirmModal document.getElementById(confirm-modal); const totpModalOverlay document.getElementById(totp-modal-overlay); const totpModal document.getElementById(totp-modal); const totpModalBody document.getElementById(totp-modal-body); const totpBtn document.getElementById(totp-btn); const newUserBtn document.getElementById(new-user-btn); const createProxyBtn document.getElementById(create-proxy-btn); // Hide all modals and overlays on page load function hideAllModals() { userModalOverlay.classList.remove(show); userModal.style.display none; proxyModalOverlay.classList.remove(show); proxyModal.style.display none; promptModal.classList.remove(show); confirmModal.classList.remove(show); totpModalOverlay.classList.remove(show); totpModal.style.display none; } // Show user modal function openUserModal(isEdit false) { state.editingUsername isEdit ? state.editingUsername : null; // Reset temp permissions every time we open the modal if (typeof tempUserPerms ! undefined) tempUserPerms null; document.getElementById(user-modal-title).textContent isEdit ? Editar utilizador : Novo utilizador; const submitBtn document.getElementById(user-form-submit); if (submitBtn) submitBtn.textContent isEdit ? Editar Utilizador : Criar Utilizador; document.getElementById(new-user).value ; document.getElementById(new-user).disabled isEdit; document.getElementById(new-email).value ; document.getElementById(new-password).value ; const passwordInput document.getElementById(new-password); if (passwordInput) { passwordInput.required !isEdit; passwordInput.placeholder isEdit ? Deixar vazio para manter : ; } document.getElementById(new-role).value user; document.getElementById(new-limit).value ; document.getElementById(new-disabled).checked false; // Reset permissions UI const permissionListEl document.getElementById(permission-menu); if (permissionListEl) permissionListEl.innerHTML ; // preview removed userModalOverlay.classList.add(show); userModal.style.display block; } function closeUserModal() { userModalOverlay.classList.remove(show); userModal.style.display none; state.editingUsername null; if (typeof tempUserPerms ! undefined) tempUserPerms null; const passwordInput document.getElementById(new-password); if (passwordInput) { passwordInput.required true; passwordInput.placeholder ; } } // Show proxy modal function openProxyModal() { // Clear all fields every time modal opens document.getElementById(domain).value ; document.getElementById(proxy_type).value ; const ownerSelect document.getElementById(owner); if (ownerSelect) ownerSelect.value state.currentUser.username; proxyModalOverlay.classList.add(show); proxyModal.style.display block; } function closeProxyModal() { proxyModalOverlay.classList.remove(show); proxyModal.style.display none; } // Add event listeners for modal open/close document.addEventListener(DOMContentLoaded, function() { // Defer revealing the app until auth check completes hideAllModals(); const hasSession !!sessionStorage.getItem(caravelaAuthHeader) || !!localStorage.getItem(STORAGE_KEY); if (!hasSession) { // No stored session, show login immediately document.body.classList.remove(booting); // Keep app hidden until login success } else { // Stored session present: pre-hide login to avoid blink while validating const loginEl document.getElementById(login-modal); if (loginEl) loginEl.classList.add(hidden); } // User modal open/close if (newUserBtn) newUserBtn.addEventListener(click, () > openUserModal(false)); document.getElementById(user-modal-close).addEventListener(click, closeUserModal); document.getElementById(user-form-cancel).addEventListener(click, closeUserModal); // Proxy modal open/close if (createProxyBtn) createProxyBtn.addEventListener(click, openProxyModal); document.getElementById(proxy-modal-close).addEventListener(click, closeProxyModal); document.getElementById(proxy-form-cancel).addEventListener(click, closeProxyModal); document.getElementById(totp-modal-close).addEventListener(click, closeTotpModal); totpModalOverlay.addEventListener(click, function(ev) { if (ev.target totpModalOverlay) closeTotpModal(); }); // Populate owner dropdown for proxy modal const ownerFieldDiv document.getElementById(owner-field); if (ownerFieldDiv) ownerFieldDiv.style.display none; // ESC key closes modals document.addEventListener(keydown, function(e) { if (e.key Escape) { if (userModalOverlay.classList.contains(show)) closeUserModal(); if (proxyModalOverlay.classList.contains(show)) closeProxyModal(); if (promptModal.classList.contains(show)) closePromptModal(); if (confirmModal.classList.contains(show)) closeConfirmModal(); if (totpModalOverlay.classList.contains(show)) closeTotpModal(); if (tutorialSelectOverlay && tutorialSelectOverlay.classList.contains(show)) closeTutorialSelector(); } }); // Logout button document.getElementById(logout-btn).addEventListener(click, function() { localStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(caravelaAuthHeader); sessionStorage.removeItem(caravelaRemember); stopAutoRefresh(); state.currentUser null; state.authHeader null; state.proxies ; state.users ; state.emulators ; state.settings { auto_refresh_enabled: false }; state.totp { enabled: false, pending: false, secret: null, otpauth_url: null, blocking: false }; state.rememberLogin false; const rememberCheckbox document.getElementById(login-remember); if (rememberCheckbox) rememberCheckbox.checked false; updateProxyStats(); if (proxiesContainer) proxiesContainer.innerHTML div classempty>Inicia sessão para gerir proxys./div>; const usernameDisplay document.getElementById(topbar-username); const ipDisplay document.getElementById(topbar-ip); if (usernameDisplay) usernameDisplay.textContent —; if (ipDisplay) ipDisplay.textContent —; updateAutoRefreshUI(); updateTotpButtonState(); closeTotpModal(); document.getElementById(app-root).style.display none; document.getElementById(login-modal).classList.remove(hidden); }); // Guia/User Guide button document.getElementById(tutorial-btn).addEventListener(click, function() { openTutorialSelector(); }); const startTutorialLink document.getElementById(start-tutorial); if (startTutorialLink) { startTutorialLink.addEventListener(click, function(ev) { ev.preventDefault(); openTutorialSelector(); }); } }); // Also hide all modals and initialize UI event listeners after login async function afterLoginInit() { hideAllModals(); setTotpStateFromUser(state.currentUser); updateActionVisibility(); populateOwnerDropdowns(); // Initialize proxy search functionality (domain-only) const searchInput document.getElementById(proxy-search-input); if (searchInput) { const applySearch () > { const q searchInput.value.toLowerCase().trim(); const original state.proxies.slice(); const filtered q ? original.filter(p > p.domain.toLowerCase().includes(q)) : original; state.proxies filtered; renderProxiesTable(); state.proxies original; }; searchInput.addEventListener(input, applySearch); const clearSearchBtn document.getElementById(clear-search-btn); if (clearSearchBtn) clearSearchBtn.addEventListener(click, () > { searchInput.value; applySearch(); }); } document.querySelectorAll(.nav-btndata-tab).forEach(btn > { if (btn.dataset.bound true) return; btn.dataset.bound true; btn.addEventListener(click, async function() { const tab btn.getAttribute(data-tab); if (!tab) return; // Gate restricted tabs for non-admins const requires btn.getAttribute(data-requires); const isAdmin state.currentUser && state.currentUser.role admin; if (requires admin && !isAdmin) { showToast(Acesso restrito a administradores., danger); return; } state.activeTab tab; document.querySelectorAll(.nav-btndata-tab).forEach(b > b.classList.remove(active)); btn.classList.add(active); document.querySelectorAll(.tab-content).forEach(sec > sec.classList.remove(active)); const tabSection document.getElementById(tab- + tab); if (tabSection) tabSection.classList.add(active); if (tab users) { await renderUsersTab(); } else if (tab emulators) { await loadEmulators({ silent: true, force: true }); } }); }); // Apply locked visual state to restricted tabs if user is not admin const isAdmin state.currentUser && state.currentUser.role admin; document.querySelectorAll(.nav-btndata-requiresadmin).forEach(btn > { if (!isAdmin) { btn.classList.add(locked); btn.setAttribute(title, Apenas para administradores); } else { btn.classList.remove(locked); btn.removeAttribute(title); } }); // Hide restricted tab sections for non-admin users document.querySelectorAll(.tab-contentdata-requiresadmin).forEach(sec > { if (!isAdmin) { sec.setAttribute(aria-hidden, true); sec.classList.remove(active); } else { sec.removeAttribute(aria-hidden); } }); // Ensure non-admins fall back to proxies tab if (!isAdmin) { state.activeTab proxies; document.querySelectorAll(.nav-btndata-tab).forEach(b > b.classList.remove(active)); const proxiesBtn document.querySelector(.nav-btndata-tabproxies); if (proxiesBtn) proxiesBtn.classList.add(active); document.querySelectorAll(.tab-content).forEach(sec > sec.classList.remove(active)); const proxiesTab document.getElementById(tab-proxies); if (proxiesTab) proxiesTab.classList.add(active); } const refreshBtn document.getElementById(refresh-btn); if (refreshBtn && refreshBtn.dataset.bound ! true) { refreshBtn.dataset.bound true; refreshBtn.addEventListener(click, () > loadProxies()); } const reloadBtn document.getElementById(reload-btn); if (reloadBtn && reloadBtn.dataset.bound ! true) { reloadBtn.dataset.bound true; reloadBtn.addEventListener(click, async () > { const confirmed await openConfirmModal( Recarregar Nginx, `div stylecolor:var(--text);> p stylecolor:var(--warning);font-weight:600;margin-bottom:1rem;>⚠️ Aviso: Esta é uma operação potencialmente disruptiva./p> p>Esta ação irá:/p> ul stylemargin-bottom:1rem;> li>Reiniciar o motor Nginx/li> li>Recarregar todas as configurações/li> li>Pode causar interrupção momentânea de serviço/li> /ul> p>Certifica-te que:/p> ul stylemargin-bottom:1rem;> li>Não há tráfego crítico neste momento/li> li>Todas as configurações estão corretas/li> li>A equipa foi notificada desta manutenção/li> /ul> p>Tens a certeza de que queres prosseguir?/p> /div>`, { allowHTML: true } ); if (!confirmed) return; await reloadNginx(); }); } const autoRefreshBtn document.getElementById(auto-refresh-btn); if (autoRefreshBtn && autoRefreshBtn.dataset.bound ! true) { autoRefreshBtn.dataset.bound true; autoRefreshBtn.addEventListener(click, toggleAutoRefresh); } if (totpBtn && totpBtn.dataset.bound ! true) { totpBtn.dataset.bound true; totpBtn.addEventListener(click, () > openTotpModal(false)); } const cfRefreshBtn document.getElementById(cf-refresh-btn); if (cfRefreshBtn && cfRefreshBtn.dataset.bound ! true) { cfRefreshBtn.dataset.bound true; cfRefreshBtn.addEventListener(click, handleCloudflareCheck); } const createProxyBtn document.getElementById(create-proxy-btn); if (createProxyBtn && createProxyBtn.dataset.bound ! true) { createProxyBtn.dataset.bound true; createProxyBtn.addEventListener(click, openProxyModal); } const newUserBtn document.getElementById(new-user-btn); if (newUserBtn && newUserBtn.dataset.bound ! true) { newUserBtn.dataset.bound true; newUserBtn.addEventListener(click, () > openUserModal(false)); } const usernameDisplay document.getElementById(topbar-username); const ipDisplay document.getElementById(topbar-ip); if (state.currentUser) { if (usernameDisplay) { usernameDisplay.textContent state.currentUser.username || —; } if (ipDisplay) { ipDisplay.textContent …; fetch(https://api.ipify.org?formatjson).then(r > r.json()).then(data > { ipDisplay.textContent data.ip; }).catch(() > { ipDisplay.textContent window.location.hostname; }); } } else { if (usernameDisplay) usernameDisplay.textContent —; if (ipDisplay) ipDisplay.textContent —; } updateTotpButtonState(); if (!state.fetchWrapped) { const originalFetch window.fetch.bind(window); window.fetch async function(url, opts {}) { const headers opts.headers instanceof Headers ? opts.headers : new Headers(opts.headers || {}); if (state.authHeader && !headers.has(Authorization)) { headers.set(Authorization, state.authHeader); } const res await originalFetch(url, { ...opts, headers }); if (res.status 401) { try { const data await res.clone().json(); const detail (data && (data.detail || data.error || )).toString().toLowerCase(); const booting document.body.classList.contains(booting); if (!booting && detail.includes(totp)) { state.totp.blocking true; // Open TOTP modal in blocking mode so user cant continue without verifying if (typeof openTotpModal function) openTotpModal(true); } } catch (_) { // ignore } } return res; }; state.fetchWrapped true; } await loadSettings(); await loadProxies(); updateUserBadge(); if (canManageUsers()) { await renderUsersTab(true); } else { state.users ; const usersContainer document.getElementById(users-container); if (usersContainer) usersContainer.innerHTML div classempty>Sem permissões para gerir utilizadores./div>; } if (hasPermission(emulators)) { await loadEmulators({ silent: true, force: true }); } else { state.emulators ; const emuContainer document.getElementById(emulator-container); if (emuContainer) emuContainer.innerHTML div classempty>Sem permissões para ver emuladores./div>; } populateOwnerDropdowns(); resetEmulatorForm(); } // (old permissions dropdown renderer removed) async function refreshCurrentUser() { try { const res await fetch(/api/me); if (!res.ok) throw new Error(Não foi possível atualizar a sessão.); const user await res.json(); state.currentUser user; setTotpStateFromUser(user); updateUserBadge(); if (state.activeTab users) { await renderUsersTab(true); } } catch (err) { showToast(err.message || Erro ao atualizar o utilizador., danger); } } async function startTotpSetup() { showLoading(); try { const res await fetch(/api/me/totp/setup, { method: POST }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.detail || data.error || Não foi possível iniciar o 2FA.); } state.totp.pending true; state.totp.secret data.secret; state.totp.otpauth_url data.otpauth_url; if (state.currentUser) { state.currentUser.totp_pending true; } renderTotpModal(); updateTotpButtonState(); showToast(Configuração 2FA iniciada., success); } catch (err) { showToast(err.message || Erro ao iniciar 2FA., danger); } finally { hideLoading(); } } async function confirmTotpSetup(code) { showLoading(); try { const res await fetch(/api/me/totp/enable, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code }) }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.detail || data.error || Código inválido.); } state.totp.enabled true; state.totp.pending false; state.totp.secret null; state.totp.otpauth_url null; state.totp.blocking false; if (state.currentUser) { state.currentUser.totp_enabled true; state.currentUser.totp_pending false; } updateTotpButtonState(); await refreshCurrentUser(); closeTotpModal(true); showToast(2FA ativado com sucesso., success); } catch (err) { showToast(err.message || Erro ao validar código., danger); } finally { hideLoading(); } } async function verifyTotp(code) { showLoading(); try { const headers { X-TOTP: code }; const res await fetch(/api/totp/verify, { method: POST, headers }); let data {}; try { data await res.json(); } catch (_) { data {}; } if (!res.ok || data.ok false) { throw new Error((data && (data.detail || data.error)) || Código inválido.); } state.totp.blocking false; await refreshCurrentUser(); closeTotpModal(true); showToast(Sessão verificada com 2FA., success); } catch (err) { showToast((err && err.message) || Código inválido., danger); } finally { hideLoading(); } } async function cancelTotpSetup() { showLoading(); try { const res await fetch(/api/me/totp/cancel, { method: POST }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.detail || data.error || Não foi possível cancelar.); } state.totp.pending false; state.totp.secret null; state.totp.otpauth_url null; if (state.currentUser) { state.currentUser.totp_pending false; } updateTotpButtonState(); renderTotpModal(); await refreshCurrentUser(); showToast(Configuração 2FA cancelada., success); } catch (err) { showToast(err.message || Erro ao cancelar 2FA., danger); } finally { hideLoading(); } } async function disableTotp(code) { showLoading(); try { const headers { Content-Type: application/json, X-TOTP: code }; const res await fetch(/api/me/totp/disable, { method: POST, headers, body: JSON.stringify({ code }) }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.detail || data.error || Não foi possível desativar 2FA.); } state.totp.enabled false; state.totp.pending false; state.totp.secret null; state.totp.otpauth_url null; if (state.currentUser) { state.currentUser.totp_enabled false; state.currentUser.totp_pending false; } updateTotpButtonState(); await refreshCurrentUser(); closeTotpModal(true); showToast(2FA desativado., success); } catch (err) { showToast(err.message || Erro ao desativar 2FA., danger); } finally { hideLoading(); } } function updateProxyStats(list ) { const currentUser state.currentUser; if (!currentUser) return; const isElevated currentUser.role admin || currentUser.role captain; const userProxies isElevated ? list : list.filter(p > p.owner currentUser.username); const total userProxies.length; // Respect unlimited: null/undefined -> —; 0 -> unlimited; positive -> number const hasOwnLimit !isElevated; let proxyLimitValue null; if (hasOwnLimit) { const eff (currentUser.effective_max_proxies ! undefined) ? currentUser.effective_max_proxies : currentUser.max_proxies; if (typeof eff number) proxyLimitValue eff; else if (eff null || eff undefined) proxyLimitValue null; else proxyLimitValue Number(eff) || 0; } const proxyLimitLabel isElevated ? — : (proxyLimitValue null ? — : String(proxyLimitValue)); const blocked userProxies.filter(p > p.blocked).length; const expiring userProxies.filter(p > p.ssl active && p.days_left ! null && p.days_left 10); // Update proxy count const proxyCount document.getElementById(proxy-count); if (proxyCount) { const countValue `${total}/${proxyLimitLabel}`; proxyCount.querySelector(.value).textContent countValue; const isFull hasOwnLimit && typeof proxyLimitValue number && proxyLimitValue > 0 && total > proxyLimitValue; proxyCount.className proxy-count + (total 0 ? normal : isFull ? full : active); // Disable create button when at limit for non-elevated users const createBtn document.getElementById(create-proxy-btn); if (createBtn) { if (isFull) { createBtn.disabled true; createBtn.title Limite de proxies atingido; createBtn.setAttribute(aria-disabled, true); } else { createBtn.disabled false; createBtn.title + Criar proxy; createBtn.removeAttribute(aria-disabled); } } } // Update blocked status const blockedStatus document.getElementById(proxy-blocked); if (blockedStatus) { blockedStatus.style.display blocked > 0 ? : none; blockedStatus.querySelector(span:last-child).textContent `${blocked} bloqueada${blocked ! 1 ? s : }`; } // Update expiring warning const expiringWarning document.getElementById(proxy-expiring); if (expiringWarning) { if (expiring.length > 0) { expiringWarning.style.display ; const tooltipContent expiring.map(p > `${p.domain} (${p.days_left} dias)`).join(\\n); expiringWarning.title tooltipContent; } else { expiringWarning.style.display none; } } // Update user filter if admin/captain const userFilter document.getElementById(proxy-user-filter); if (userFilter && currentUser && (currentUser.role admin || currentUser.role captain)) { userFilter.style.display ; const users ...new Set(list.map(p > p.owner)).filter(Boolean).sort(); userFilter.innerHTML option value>Todos os utilizadores/option> + users.map(user > `option value${escapeHtml(user)}>${escapeHtml(user)}/option>`).join(); } } function sortHeaderClass(field) { if (state.sortField ! field) return sortable; return `sortable ${state.sortDirection asc ? sorted-asc : sorted-desc}`; } function getSortedProxies() { const proxies ...state.proxies; const field state.sortField || domain; const direction state.sortDirection desc ? -1 : 1; proxies.sort((a, b) > { const av afield; const bv bfield; if (field days_left) { const ai av null || av undefined ? Number.MAX_SAFE_INTEGER : av; const bi bv null || bv undefined ? Number.MAX_SAFE_INTEGER : bv; return (ai - bi) * direction; } if (typeof av string && typeof bv string) { return av.localeCompare(bv) * direction; } if (av bv) return 0; if (av null || av undefined) return 1 * direction; if (bv null || bv undefined) return -1 * direction; return (av > bv ? 1 : -1) * direction; }); return proxies; } function updateProxyDependentSelects() { const redirectSelect document.getElementById(redirect-proxy); const tvSelect document.getElementById(tvonline-proxy); const proxies state.proxies || ; if (redirectSelect) { const prev redirectSelect.value; redirectSelect.innerHTML option value disabled selected>Selecione uma proxy/option>; proxies.forEach(proxy > { const opt document.createElement(option); opt.value proxy.domain; opt.textContent `${proxy.domain} (${proxy.type})`; redirectSelect.appendChild(opt); }); if (prev && proxies.some(p > p.domain prev)) { redirectSelect.value prev; } } if (tvSelect) { const prevTv tvSelect.value; tvSelect.innerHTML option value disabled selected>Selecione um Portal/option>; proxies.filter(p > p.type && p.type.toLowerCase().includes(portal)).forEach(proxy > { const opt document.createElement(option); opt.value proxy.domain; opt.textContent `${proxy.domain} (${proxy.type})`; tvSelect.appendChild(opt); }); if (prevTv && proxies.some(p > p.domain prevTv)) { tvSelect.value prevTv; } } } function updateUserBadge() { const badge document.getElementById(user-badge); if (!badge || !state.currentUser) return; const roleLabel state.currentUser.role ? state.currentUser.role.toUpperCase() : ; badge.textContent `${state.currentUser.username} · ${roleLabel}`; } function bindProxyTableEvents(container) { container.querySelectorAll(th.sortable).forEach(th > { th.addEventListener(click, () > { const field th.dataset.sort; if (!field) return; if (state.sortField field) { state.sortDirection state.sortDirection asc ? desc : asc; } else { state.sortField field; state.sortDirection asc; } renderProxiesTable(); }); }); container.querySelectorAll(data-actioncheck).forEach(btn > { btn.addEventListener(click, async () > { const domain btn.dataset.domain; if (!domain) return; await handleCheck(domain); }); }); container.querySelectorAll(data-actionreissue).forEach(btn > { btn.addEventListener(click, async () > { const domain btn.dataset.domain; if (!domain) return; await handleReissue(domain); }); }); container.querySelectorAll(data-actionblock).forEach(btn > { btn.addEventListener(click, async () > { const domain btn.dataset.domain; const blocked btn.dataset.blocked true; if (!domain) return; await handleBlock(domain, blocked); }); }); container.querySelectorAll(data-actiondelete).forEach(btn > { btn.addEventListener(click, async () > { const domain btn.dataset.domain; if (!domain) return; await handleDelete(domain); }); }); } function renderProxiesTable() { if (!proxiesContainer) return; const list getSortedProxies(); updateProxyStats(list); updateUserBadge(); if (!list.length) { proxiesContainer.innerHTML div classempty>Nenhuma proxy configurada ainda./div>; updateProxyDependentSelects(); return; } const canElevated canManageProxies(); const rows list.map(proxy > { const sslStatusRaw proxy.ssl || pending; const sslStatus sslStatusRaw.toUpperCase(); const blockedClass proxy.blocked ? is-blocked : ; const isActive proxy.ssl active && proxy.cloudflare_proxy true; const statusChipClass proxy.blocked ? status-chip blocked : `status-chip ${isActive ? active : }`; const sslDotColor proxy.ssl active ? #10b981 : (proxy.ssl pending ? #f59e0b : #ef4444); const sslText (() > { if (proxy.ssl active) { if (proxy.expires_at) return `Expira: ${escapeHtml(formatDate(proxy.expires_at))} (${proxy.days_left}d)`; return Ativo; } if (proxy.ssl pending) return Pendente; if (proxy.ssl expired) return Expirado; return Desconhecido; })(); const actions ; if (canElevated) { actions.push(`button typebutton data-actioncheck data-domain${escapeHtml(proxy.domain)}>Verificar/button>`); const unblockDisabled proxy.blocked && proxy.blocked_auto && state.currentUser && state.currentUser.role ! admin; const disableReissue proxy.cloudflare_proxy true; // only block when CF is ON const reissueTitle disableReissue ? Desative a nuvem laranja na Cloudflare antes de reemitir : Reemitir certificado SSL; actions.push(`button typebutton data-actionreissue data-domain${escapeHtml(proxy.domain)} ${disableReissue ? disabled : } title${reissueTitle}>Reemitir SSL/button>`); actions.push(`button typebutton class${proxy.blocked ? : danger} data-actionblock data-domain${escapeHtml(proxy.domain)} data-blocked${proxy.blocked} ${unblockDisabled ? disabled title\Auto-bloqueio ativo (admin apenas)\ : }>${proxy.blocked ? Desbloquear : Bloquear}/button>`); actions.push(`button typebutton classdanger data-actiondelete data-domain${escapeHtml(proxy.domain)}>Apagar/button>`); } const typePill proxy.type ? `span classtype-pill>${escapeHtml(proxy.type)}/span>` : ; const statusLabel proxy.blocked ? Bloqueada : ; const statusChip statusLabel ? `span class${statusChipClass}>${escapeHtml(statusLabel)}/span>` : ; const blockedNote proxy.blocked_reason ? `div classblocked-note>${escapeHtml(proxy.blocked_reason)}/div>` : ; return ` tr class${blockedClass}> td classdomain-cell> div classdomain-top> a hrefhttps://${escapeHtml(proxy.domain)} target_blank relnoopener classdomain-link>${escapeHtml(proxy.domain)}/a> ${typePill} /div> div classstatus-row> ${statusChip} /div> ${blockedNote} /td> td> div classinfo-stack> div classinfo-line> span classinfo-label>Owner/span> span classinfo-value>${escapeHtml(proxy.owner || —)}/span> /div> div classinfo-line> span classinfo-label>Criada/span> span classinfo-value>${escapeHtml(formatDate(proxy.created_at))}/span> /div> /div> /td> td> div classinfo-stack title${escapeHtml(sslStatus)}> div classinfo-line> span classinfo-label> /span> span classinfo-value styledisplay:inline-flex;align-items:center;gap:.5rem;> span styledisplay:inline-block;width:10px;height:10px;border-radius:999px;background:${sslDotColor};box-shadow:0 0 0 2px rgba(255,255,255,0.08);>/span> span>${sslText}/span> /span> /div> /div> /td> td> div classcloud-indicator ${proxy.cloudflare_proxy ? active : } titleCloudflare> img src/static/assets/cloudflare-cloud.svg altCloudflare stylewidth:50px;height:50px;filter:${proxy.cloudflare_proxy ? none : grayscale(1) brightness(0.8)};opacity:${proxy.cloudflare_proxy ? 1 : .6};> /div> /td> td> div classactions> ${actions.join()} /div> /td> /tr> `; }).join(); proxiesContainer.innerHTML ` table> thead> tr> th class${sortHeaderClass(domain)} data-sortdomain>Domínio/th> th class${sortHeaderClass(owner)} data-sortowner>Owner/th> th class${sortHeaderClass(days_left)} data-sortdays_left>SSL/th> th class${sortHeaderClass(cloudflare_proxy)} data-sortcloudflare_proxy>Cloudflare/th> th>Ações/th> /tr> /thead> tbody> ${rows} /tbody> /table> `; bindProxyTableEvents(proxiesContainer); updateProxyDependentSelects(); } async function loadProxies(options {}) { const { silent false } options; if (!hasPermission(proxies)) { state.proxies ; renderProxiesTable(); return; } if (!silent) showLoading(); try { const res await fetch(/api/proxies); if (!res.ok) throw new Error(Erro ao carregar proxys.); const data await res.json(); if (!Array.isArray(data)) throw new Error(Resposta inválida das proxys.); state.proxies data; // Apply domain-only search filter if any const searchInput document.getElementById(proxy-search-input); if (searchInput && searchInput.value) { const q searchInput.value.toLowerCase().trim(); const original state.proxies.slice(); const filtered original.filter(p > p.domain.toLowerCase().includes(q)); state.proxies filtered; renderProxiesTable(); state.proxies original; } else { renderProxiesTable(); } } catch (err) { proxiesContainer.innerHTML `div classempty>${escapeHtml(err.message || Erro ao carregar proxys.)}/div>`; showToast(err.message || Erro ao carregar proxys., danger); } finally { if (!silent) hideLoading(); } } async function handleReissue(domain) { if (!canManageProxies()) return; showLoading(); try { const res await fetch(`/api/proxies/${encodeURIComponent(domain)}/reissue`, { method: POST }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { let msg data.error || Falha ao reemitir SSL.; const detail (data && (data.detail || data.message)) ? String(data.detail || data.message) : ; if (detail) { // Show only the last lines to avoid huge toasts const lines detail.split(\n); const tail lines.slice(Math.max(0, lines.length - 8)); msg + `\n\n» Detalhe:\n${tail.join(\n)}`; } throw new Error(msg); } showToast(`Pedido de reemissão enviado para ${domain}.`, success); await loadProxies({ silent: true }); } catch (err) { showToast(err.message || Erro ao reemitir SSL., danger); } finally { hideLoading(); } } async function handleCheck(domain) { if (!hasPermission(proxies)) return; showLoading(); try { const res await fetch(`/api/proxies/${encodeURIComponent(domain)}/check`, { method: POST }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Falha ao verificar proxy.); } await loadProxies({ silent: true }); const cf data.cloudflare_proxy true ? CF: OK : CF: off; const ssl `SSL: ${data.ssl || —}`; const blocked data.blocked ? bloqueada : ativa; showToast(`${domain} · ${cf} · ${ssl} · ${blocked}${data.changed ? (atualizado) : }`, data.blocked ? danger : success); } catch (err) { showToast(err.message || Erro na verificação., danger); } finally { hideLoading(); } } async function handleBlock(domain, currentlyBlocked) { if (!canManageProxies()) return; let reason ; if (!currentlyBlocked) { reason await openPromptModal(Bloquear proxy, Motivo (opcional), , text); if (reason null) return; } else { const confirmed await openConfirmModal(Desbloquear proxy, `Pretende desbloquear ${domain}?`); if (!confirmed) return; } showLoading(); try { const form new FormData(); form.append(blocked, (!currentlyBlocked).toString()); if (!currentlyBlocked && reason) form.append(reason, reason); const res await fetch(`/api/proxies/${encodeURIComponent(domain)}/block`, { method: POST, body: form }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { // If admin is unblocking an auto-blocked proxy, offer force override const isAdmin state.currentUser && state.currentUser.role admin; if (currentlyBlocked && isAdmin) { const force await openConfirmModal(Forçar desbloqueio, As condições (SSL/Cloudflare) não estão válidas. Forçar desbloqueio temporário?); if (force) { const form2 new FormData(); form2.append(blocked, false); form2.append(force, true); const res2 await fetch(`/api/proxies/${encodeURIComponent(domain)}/block`, { method: POST, body: form2 }); let data2 {}; try { data2 await res2.json(); } catch (_) {} if (!res2.ok || data2.ok false) { throw new Error(data2.error || Falha ao forçar desbloqueio.); } showToast(`Proxy ${domain} desbloqueada (override temporário).`, success); await loadProxies({ silent: true }); return; } } throw new Error(data.error || Não foi possível alterar o estado da proxy.); } showToast(currentlyBlocked ? `Proxy ${domain} desbloqueada.` : `Proxy ${domain} bloqueada.`, success); await loadProxies({ silent: true }); } catch (err) { showToast(err.message || Erro ao alterar bloqueio., danger); } finally { hideLoading(); } } async function handleDelete(domain) { if (!canManageProxies()) return; const confirmed await openConfirmModal(Remover proxy, `Tem a certeza que pretende remover ${domain}?`); if (!confirmed) return; showLoading(); try { const res await fetch(`/api/proxies/${encodeURIComponent(domain)}`, { method: DELETE }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Não foi possível remover a proxy.); } showToast(`Proxy ${domain} removida.`, success); await loadProxies({ silent: true }); } catch (err) { showToast(err.message || Erro ao remover proxy., danger); } finally { hideLoading(); } } async function reloadNginx() { if (!canManageProxies()) return; showLoading(); try { const res await fetch(/api/reload, { method: POST }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Falha ao recarregar Nginx.); } showToast(Nginx recarregado., success); } catch (err) { showToast(err.message || Erro ao recarregar Nginx., danger); } finally { hideLoading(); } } function stopAutoRefresh() { if (state.autoRefreshTimer) { clearInterval(state.autoRefreshTimer); state.autoRefreshTimer null; } } function startAutoRefresh() { stopAutoRefresh(); state.autoRefreshTimer setInterval(() > { loadProxies({ silent: true }); }, AUTO_REFRESH_INTERVAL); } function updateAutoRefreshUI() { const btn document.getElementById(auto-refresh-btn); if (!btn) return; if (!canManageProxies()) { btn.classList.remove(active); btn.title Auto refresh desligado; btn.setAttribute(aria-label, Auto refresh desligado); btn.setAttribute(aria-pressed, false); btn.style.display none; stopAutoRefresh(); return; } btn.style.display inline-flex; btn.textContent ⟳; if (state.settings.auto_refresh_enabled) { btn.classList.add(active); const intervalSeconds Math.round(AUTO_REFRESH_INTERVAL / 1000); btn.title `Auto refresh ligado (${intervalSeconds}s)`; btn.setAttribute(aria-label, `Auto refresh ligado (${intervalSeconds}s)`); btn.setAttribute(aria-pressed, true); startAutoRefresh(); } else { btn.classList.remove(active); btn.title Auto refresh desligado; btn.setAttribute(aria-label, Auto refresh desligado); btn.setAttribute(aria-pressed, false); stopAutoRefresh(); } } async function toggleAutoRefresh() { if (!canManageProxies()) return; const desired !state.settings.auto_refresh_enabled; const params new URLSearchParams(); params.append(enabled, desired ? true : false); showLoading(); try { const res await fetch(/api/settings/auto-refresh, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: params.toString() }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.error || Não foi possível alterar auto refresh.); } state.settings.auto_refresh_enabled !!data.auto_refresh_enabled; updateAutoRefreshUI(); showToast(state.settings.auto_refresh_enabled ? Auto refresh ligado. : Auto refresh desligado., success); } catch (err) { showToast(err.message || Erro ao alterar auto refresh., danger); } finally { hideLoading(); } } async function loadSettings() { if (!canManageProxies()) { state.settings { auto_refresh_enabled: false }; updateAutoRefreshUI(); return; } try { const res await fetch(/api/settings); if (res.status 403) { state.settings { auto_refresh_enabled: false }; updateAutoRefreshUI(); return; } if (!res.ok) throw new Error(Erro ao obter definições.); const data await res.json(); state.settings data; } catch (err) { state.settings { auto_refresh_enabled: false }; showToast(err.message || Erro ao obter definições., danger); } updateAutoRefreshUI(); } function updateActionVisibility() { const canElevated canManageProxies(); const reloadBtn document.getElementById(reload-btn); const createProxyBtn document.getElementById(create-proxy-btn); if (reloadBtn) reloadBtn.style.display canElevated ? inline-flex : none; if (createProxyBtn) createProxyBtn.style.display hasPermission(proxies) ? inline-flex : none; const usersNavBtn document.querySelector(.nav-btndata-tabusers); if (usersNavBtn) usersNavBtn.style.display canManageUsers() ? inline-flex : none; const usersTab document.getElementById(tab-users); if (usersTab) usersTab.style.display canManageUsers() ? : none; const emuNavBtn document.querySelector(.nav-btndata-tabemulators); const emuTab document.getElementById(tab-emulators); const emuVisible hasPermission(emulators); if (emuNavBtn) emuNavBtn.style.display emuVisible ? inline-flex : none; if (emuTab) emuTab.style.display emuVisible ? : none; updateAutoRefreshUI(); updateTotpButtonState(); } function populateOwnerDropdowns() { const ownerFieldDiv document.getElementById(owner-field); const ownerSelect document.getElementById(owner); const emuOwnerField document.getElementById(emu-owner-field); const emuOwnerSelect document.getElementById(emu-owner); const owners ; const seen new Set(); if (state.currentUser) { owners.push(state.currentUser); seen.add(state.currentUser.username); } if (canManageUsers()) { state.users.forEach(user > { if (!seen.has(user.username)) { owners.push(user); seen.add(user.username); } }); } if (ownerFieldDiv) ownerFieldDiv.style.display canManageProxies() ? : none; if (ownerSelect) { ownerSelect.innerHTML ; owners.forEach(user > { const opt document.createElement(option); opt.value user.username; opt.textContent user.username + (user.disabled ? (desativado) : ); opt.dataset.email user.email || ; ownerSelect.appendChild(opt); }); if (state.currentUser) ownerSelect.value state.currentUser.username; } if (emuOwnerField) emuOwnerField.style.display canManageProxies() ? : none; if (emuOwnerSelect) { emuOwnerSelect.innerHTML ; owners.forEach(user > { const opt document.createElement(option); opt.value user.username; opt.textContent user.username + (user.disabled ? (desativado) : ); emuOwnerSelect.appendChild(opt); }); if (state.currentUser) emuOwnerSelect.value state.currentUser.username; } } async function handleCloudflareCheck() { if (!hasPermission(proxies)) return; showLoading(); try { const res await fetch(/api/proxies/check-all, { method: POST }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Falha ao verificar Cloudflare/SSL); } await loadProxies({ silent: true }); const total data.total ?? state.proxies.length; const cfOk data.cloudflare_ok ?? state.proxies.filter(p > p.cloudflare_proxy).length; const pending data.ssl_pending_or_invalid ?? state.proxies.filter(p > p.ssl ! active).length; showToast(`Verificação concluída. Cloudflare ativos: ${cfOk}/${total}. SSL pendentes/expirados: ${pending}. Atualizados: ${data.updated}.`, success); } catch (err) { showToast(err.message || Erro na verificação., danger); } finally { hideLoading(); } } function resetEmulatorForm() { const form document.getElementById(emulator-form); if (!form) return; form.reset(); state.editingEmulatorId null; const submitBtn document.getElementById(emu-submit); if (submitBtn) submitBtn.textContent Criar emulador; const ownerSelect document.getElementById(emu-owner); if (ownerSelect && state.currentUser) ownerSelect.value state.currentUser.username; } function populateEmulatorForm(emulator) { state.editingEmulatorId emulator.id; const submitBtn document.getElementById(emu-submit); if (submitBtn) submitBtn.textContent Guardar alterações; document.getElementById(emu-name).value emulator.name || ; document.getElementById(emu-domains).value (emulator.domains || ).join(, ); document.getElementById(emu-match).value emulator.user_agent_match || ; document.getElementById(emu-replace).value emulator.user_agent_replace || ; document.getElementById(emu-template).value emulator.protocol_template || ; document.getElementById(emu-rules).value emulator.rules ? JSON.stringify(emulator.rules, null, 2) : ; document.getElementById(emu-notes).value emulator.notes || ; document.getElementById(emu-enabled).checked !!emulator.enabled; const ownerSelect document.getElementById(emu-owner); if (ownerSelect && emulator.owner) ownerSelect.value emulator.owner; } function renderEmulators() { const container document.getElementById(emulator-container); if (!container) return; if (!hasPermission(emulators)) { container.innerHTML div classempty>Sem permissões para ver emuladores./div>; return; } if (!state.emulators.length) { container.innerHTML div classempty>Nenhum emulador configurado./div>; return; } const rows state.emulators.map(item > { const manageable canManageEmulator(item); const domains (item.domains || ).join(, ) || —; const rules Array.isArray(item.rules) && item.rules.length ? `${item.rules.length} regra(s)` : —; const statusClass item.enabled ? status-chip active : status-chip; const actions manageable ? ` button typebutton data-emulator${escapeHtml(item.id)} data-actionemu-edit>Editar/button> button typebutton data-emulator${escapeHtml(item.id)} data-actionemu-toggle>${item.enabled ? Desativar : Ativar}/button> button typebutton classdanger data-emulator${escapeHtml(item.id)} data-actionemu-delete>Apagar/button> ` : ; return ` tr> td> div styledisplay:flex;flex-direction:column;gap:0.35rem;> strong stylefont-size:1.05rem;>${escapeHtml(item.name || —)}/strong> span class${statusClass}>${item.enabled ? Ativo : Inativo}/span> /div> /td> td> div styledisplay:flex;flex-direction:column;gap:0.25rem;> span>strong>Owner:/strong> ${escapeHtml(item.owner || —)}/span> span>strong>Domínios:/strong> ${escapeHtml(domains)}/span> span>strong>Regras:/strong> ${escapeHtml(rules)}/span> /div> /td> td> div styledisplay:flex;flex-direction:column;gap:0.25rem;> span>strong>Match:/strong> ${escapeHtml(item.user_agent_match || —)}/span> span>strong>Replace:/strong> ${escapeHtml(item.user_agent_replace || —)}/span> span>strong>Criado:/strong> ${formatDateTime(item.created_at)}/span> /div> /td> td> div classactions> ${actions} /div> /td> /tr> `; }).join(); container.innerHTML ` table> thead> tr> th>Emulador/th> th>Owner & Domínios/th> th>User Agent/th> th>Ações/th> /tr> /thead> tbody> ${rows} /tbody> /table> `; container.querySelectorAll(data-actionemu-edit).forEach(btn > { btn.addEventListener(click, () > { const emulator state.emulators.find(e > e.id btn.dataset.emulator); if (emulator && canManageEmulator(emulator)) populateEmulatorForm(emulator); }); }); container.querySelectorAll(data-actionemu-toggle).forEach(btn > { btn.addEventListener(click, async () > { const emulator state.emulators.find(e > e.id btn.dataset.emulator); if (emulator && canManageEmulator(emulator)) { await toggleEmulator(emulator, !emulator.enabled); } }); }); container.querySelectorAll(data-actionemu-delete).forEach(btn > { btn.addEventListener(click, async () > { const emulator state.emulators.find(e > e.id btn.dataset.emulator); if (emulator && canManageEmulator(emulator)) { await deleteEmulator(emulator); } }); }); } async function loadEmulators(options {}) { const { silent false, force false } options; if (!hasPermission(emulators)) { state.emulators ; renderEmulators(); return; } if (!force && state.emulators.length && silent) { renderEmulators(); return; } if (!silent) showLoading(); try { const res await fetch(/api/emulators); if (!res.ok) throw new Error(Erro ao carregar emuladores.); const data await res.json(); if (!Array.isArray(data)) throw new Error(Resposta inválida ao carregar emuladores.); state.emulators data; renderEmulators(); } catch (err) { renderEmulators(); showToast(err.message || Erro ao carregar emuladores., danger); } finally { if (!silent) hideLoading(); } } async function toggleEmulator(emulator, enabled) { const form new FormData(); form.append(enabled, enabled ? true : false); showLoading(); try { const res await fetch(`/api/emulators/${encodeURIComponent(emulator.id)}/toggle`, { method: POST, body: form }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Não foi possível alterar o estado.); } showToast(`Emulador ${emulator.name} ${enabled ? ativado : desativado}.`, success); await loadEmulators({ silent: true, force: true }); } catch (err) { showToast(err.message || Erro ao alterar estado do emulador., danger); } finally { hideLoading(); } } async function deleteEmulator(emulator) { const confirmed await openConfirmModal(Eliminar emulador, `Remover ${emulator.name}?`); if (!confirmed) return; showLoading(); try { const res await fetch(`/api/emulators/${encodeURIComponent(emulator.id)}`, { method: DELETE }); let data {}; try { data await res.json(); } catch (_) {} if (!res.ok || data.ok false) { throw new Error(data.error || Não foi possível eliminar o emulador.); } showToast(`Emulador ${emulator.name} eliminado.`, success); await loadEmulators({ silent: true, force: true }); resetEmulatorForm(); } catch (err) { showToast(err.message || Erro ao eliminar emulador., danger); } finally { hideLoading(); } } async function submitEmulatorForm(form) { if (!hasPermission(emulators)) return; const formData new FormData(form); if (!formData.get(enabled)) formData.set(enabled, false); if (formData.get(rules)) { try { JSON.parse(formData.get(rules)); } catch (_) { showToast(Regras devem ser JSON válido., danger); return; } } const isEdit !!state.editingEmulatorId; let url /api/emulators; let method POST; if (isEdit) { url `/api/emulators/${encodeURIComponent(state.editingEmulatorId)}`; method PUT; } showLoading(); try { const res await fetch(url, { method, body: formData }); const data await res.json(); if (!res.ok || data.ok false) { throw new Error(data.error || Erro ao guardar emulador.); } showToast(isEdit ? Emulador atualizado. : Emulador criado., success); resetEmulatorForm(); await loadEmulators({ silent: true, force: true }); } catch (err) { showToast(err.message || Erro ao guardar emulador., danger); } finally { hideLoading(); } } // Render users tab (fetch and show user cards) async function loadUsers(force false) { if (!canManageUsers()) { state.users ; return; } if (!force && state.users.length) return; const res await fetch(/api/users); if (!res.ok) throw new Error(Erro ao carregar utilizadores); const users await res.json(); if (!Array.isArray(users)) throw new Error(Resposta inválida ao carregar utilizadores); state.users users; } async function renderUsersTab(forceReload false) { const usersContainer document.getElementById(users-container); if (!canManageUsers()) { usersContainer.innerHTML div classempty>Sem permissões para gerir utilizadores./div>; return; } usersContainer.innerHTML div classempty>Carregando utilizadores.../div>; try { await loadUsers(forceReload); if (!state.users.length) { usersContainer.innerHTML div classempty>Nenhum utilizador encontrado./div>; return; } const canEdit state.currentUser && (state.currentUser.role admin || state.currentUser.role captain); usersContainer.innerHTML ; state.users.forEach(user > { const card document.createElement(div); card.className card user-card visible + (canEdit ? hoverable : ); card.style.cursor default; const dateLabel user.created_at ? formatDate(user.created_at) : —; const totpStatus user.totp_enabled ? span stylecolor:var(--success);font-weight:600;>Ativo/span> : (user.totp_pending ? span stylecolor:var(--warning);font-weight:600;>Pendente/span> : span stylecolor:var(--text-muted);font-weight:600;>Desligado/span>); const permLabels Object.keys(user.permissions || {}).filter(key > user.permissionskey).map(key > key.charAt(0).toUpperCase() + key.slice(1)); const permsDisplay permLabels.length ? permLabels.join(, ) : Nenhum; const maxProxiesLabel user.max_proxies null || user.max_proxies undefined ? — : String(user.max_proxies); const roleLabel user.role ? user.role.charAt(0).toUpperCase() + user.role.slice(1) : User; const roleColor user.role admin ? #22d3ee : (user.role captain ? #38bdf8 : #64748b); const isDisabled !!user.disabled; const actionsHtml canEdit ? ` div classuser-card-actions> button typebutton classsecondary-btn user-edit-btn>Editar/button> button typebutton classdanger user-delete-btn>Eliminar/button> /div> ` : ; card.innerHTML ` div styledisplay:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;> h2 stylemargin:0;>${escapeHtml(user.username)}/h2> span stylepadding:0.25rem 0.6rem;border-radius:999px;font-size:0.75rem;background:${roleColor};color:#0f172a;>${escapeHtml(roleLabel)}/span> ${isDisabled ? span stylecolor:var(--danger);font-weight:600;>Desativado/span> : span stylecolor:var(--success);font-weight:600;>Ativo/span>} /div> div styledisplay:grid;gap:0.35rem;color:#fff;font-size:0.9rem;> div>b>Email:/b> ${escapeHtml(user.email || —)}/div> div>b>Menus:/b> span stylecolor:var(--accent);font-weight:500;>${escapeHtml(permsDisplay)}/span>/div> div>b>2FA:/b> ${totpStatus}/div> div>b>Max Proxies:/b> span stylecolor:var(--accent-strong);font-weight:500;>${escapeHtml(maxProxiesLabel)}/span>/div> div>b>Criado:/b> span stylecolor:var(--accent);>${escapeHtml(dateLabel)}/span>/div> /div> ${actionsHtml} `; if (canEdit) { const editBtn card.querySelector(.user-edit-btn); if (editBtn) { editBtn.addEventListener(click, evt > { evt.stopPropagation(); openEditUserModal(user); }); } const deleteBtn card.querySelector(.user-delete-btn); if (deleteBtn) { deleteBtn.addEventListener(click, async evt > { evt.stopPropagation(); await handleDeleteUser(user); }); } } usersContainer.appendChild(card); }); } catch (err) { usersContainer.innerHTML `div class\empty\>${escapeHtml(err.message || Erro ao carregar utilizadores)}/div>`; } } async function fetchUserAssets(username) { const res await fetch(`/api/users/${encodeURIComponent(username)}/assets`); let payload {}; try { payload await res.json(); } catch (_) { payload {}; } if (!res.ok || payload.ok false) { throw new Error(payload.detail || payload.error || Não foi possível carregar itens associados.); } return payload; } function buildDeleteUserMessage(user, assets) { const proxies Array.isArray(assets.proxies) ? assets.proxies : ; const emulators Array.isArray(assets.emulators) ? assets.emulators : ; let html `p>Ao eliminar strong>${escapeHtml(user.username)}/strong>, os seguintes recursos serão removidos:/p>`; if (!proxies.length && !emulators.length) { html + p>Não foram encontrados recursos associados a este utilizador./p>; } if (proxies.length) { const proxyItems proxies.slice(0, 6).map(item > { const label escapeHtml(item.domain || —); const type item.type ? ` span stylecolor:var(--text-muted);>(${escapeHtml(item.type)})/span>` : ; return `li>${label}${type}/li>`; }).join(); html + `p>strong>Proxies (${proxies.length})/strong>/p>ul classconfirm-list>${proxyItems}/ul>`; if (proxies.length > 6) { html + `p stylecolor:var(--text-muted);font-size:0.8rem;>... e mais ${proxies.length - 6} entradas./p>`; } } if (emulators.length) { const emulatorItems emulators.slice(0, 6).map(item > { const label escapeHtml(item.name || item.id || —); return `li>${label}/li>`; }).join(); html + `p>strong>Emuladores (${emulators.length})/strong>/p>ul classconfirm-list>${emulatorItems}/ul>`; if (emulators.length > 6) { html + `p stylecolor:var(--text-muted);font-size:0.8rem;>... e mais ${emulators.length - 6} entradas./p>`; } } html + p>Confirma que pretende continuar?/p>; return html; } async function handleDeleteUser(user) { if (!user) return; let assets; try { showLoading(); assets await fetchUserAssets(user.username); } catch (err) { showToast(err.message || Não foi possível carregar itens associados., danger); return; } finally { hideLoading(); } const messageHtml buildDeleteUserMessage(user, assets); const confirmed await openConfirmModal(Eliminar utilizador, messageHtml, { allowHTML: true }); if (!confirmed) return; try { showLoading(); const res await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: DELETE }); let payload {}; try { payload await res.json(); } catch (_) { payload {}; } if (!res.ok || payload.ok false) { throw new Error(payload.detail || payload.error || Não foi possível eliminar o utilizador.); } showToast(`Utilizador ${user.username} eliminado.`, success); await renderUsersTab(true); await loadProxies({ silent: true }); if (hasPermission(emulators)) { await loadEmulators({ silent: true, force: true }); } populateOwnerDropdowns(); } catch (err) { showToast(err.message || Erro ao eliminar utilizador., danger); } finally { hideLoading(); } } // Open user edit modal (pre-fill form) function openEditUserModal(user) { state.editingUsername user.username; openUserModal(true); document.getElementById(user-modal-title).textContent Editar utilizador; const submitBtn document.getElementById(user-form-submit); if (submitBtn) submitBtn.textContent Editar Utilizador; document.getElementById(new-user).value user.username; document.getElementById(new-user).disabled true; document.getElementById(new-email).value user.email; document.getElementById(new-password).value ; document.getElementById(new-role).value user.role; document.getElementById(new-limit).value user.max_proxies ?? ; document.getElementById(new-disabled).checked !!user.disabled; const permCheckboxes document.querySelectorAll(#permission-menu inputtypecheckbox); permCheckboxes.forEach(checkbox > { const key checkbox.name.replace(permission_, ); checkbox.checked !!user.permissionskey; }); } // Tutorial logic const TUTORIAL_GUIDES { userbar: { target: .topbar-user, title: Identidade da sessão, content: Informação do teu username e o teu ip público que estás ligado., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); closeUserModal(); } }, { target: #totp-btn, title: Segurança 2FA, content: ` div classtutorial-block> p>Protege a conta com a app Google Authenticator:/p> ol> li>Clica em strong>Gerir 2FA/strong> para iniciar o processo./li> li>Instala a app Google Authenticator (ou equivalente) e lê o QR code apresentado./li> li>Introduz o código de 6 dígitos gerado e confirma./li> /ol> /div> `, clone: { scale: 1.45, maxWidth: 260px }, avatar: /static/assets/guia_avatar.png, action: () > { closeTotpModal(); activateTab(proxies); } }, { target: #logout-btn, title: Terminar sessão, content: Sai em segurança e limpa as credenciais guardadas neste dispositivo., clone: { scale: 1.25, maxWidth: 240px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); closeUserModal(); } } , proxies: { target: .proxy-stats-bar, title: Resumo de proxies, content: Contador de proxies que tens ativos / limite de proxies que podes criar., clone: { scale: 1.04, maxWidth: 760px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); } }, { target: #proxy-count, title: Total do utilizador, content: Número de proxies atribuídas ao utilizador atual e o limite., clone: { scale: 1.15, maxWidth: 280px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); } }, { target: #proxy-expiring, title: A expirar, content: Certificados a expirar em ≤10 dias. Planeia a reemissão., clone: { scale: 1.15, maxWidth: 300px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); // Ensure badges are visible during tutorial const exp document.getElementById(proxy-expiring); if (exp) { const prev exp.style.display; exp.style.display inline-flex; registerTutorialCleanup(() > { exp.style.display prev; }); } } }, { target: #proxy-blocked, title: Bloqueadas, content: Proxies suspensas manualmente ou em investigação., clone: { scale: 1.15, maxWidth: 300px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); const blk document.getElementById(proxy-blocked); if (blk) { const prev blk.style.display; blk.style.display inline-flex; registerTutorialCleanup(() > { blk.style.display prev; }); } } }, { target: #create-proxy-btn, title: Criar proxy, content: Abre o formulário para definir uma nova proxy., clone: { scale: 1.15, maxWidth: 280px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(proxies); closeProxyModal(); } }, { target: #domain, title: Domínio, content: Indica o domínio que será servido (ex: painel.exemplo.com)., clone: { scale: 1.05, maxWidth: 520px }, avatar: /static/assets/guia_avatar.png, action: () > openProxyModal() }, { target: #proxy_type, title: Tipo de proxy, content: Escolhe o tipo de proxy que queres criar, e toda a configuração será feita e adaptada automaticamente., clone: { scale: 1.05, maxWidth: 520px }, avatar: /static/assets/guia_avatar.png, action: () > openProxyModal() }, { target: #create-btn, title: Submeter, content: Confirma o formulário para gerar a configuração., clone: { scale: 1.05, maxWidth: 520px }, avatar: /static/assets/guia_avatar.png, action: () > openProxyModal() }, { target: #proxy-form-cancel, title: Cancelar, content: Fecha o formulário de criar proxy., clone: { scale: 1.05, maxWidth: 520px }, avatar: /static/assets/guia_avatar.png, action: () > openProxyModal() }, { target: #refresh-btn, title: Atualizar lista, content: Atualiza manualmente o estado das proxies listadas., clone: { scale: 1.15, maxWidth: 240px }, avatar: /static/assets/guia_avatar.png, action: () > closeProxyModal() }, { target: #auto-refresh-btn, title: Auto refresh, content: Liga/desliga a atualização automática. Verde ativo., clone: { scale: 1.15, maxWidth: 260px }, avatar: /static/assets/guia_avatar.png, action: () > closeProxyModal() }, { target: #reload-btn, title: Recarregar Nginx, content: Recarrega o Nginx (apenas administradores)., clone: { scale: 1.15, maxWidth: 260px }, avatar: /static/assets/guia_avatar.png, action: () > { const reloadBtn document.getElementById(reload-btn); if (!reloadBtn) return; const previous reloadBtn.style.display; reloadBtn.style.display inline-flex; registerTutorialCleanup(() > { reloadBtn.style.display previous; }); } }, { target: #tutorial-proxy-row, title: Linha exemplo, content: Linha fictícia para explicar as colunas., clone: { scale: 1.05, maxWidth: 720px }, avatar: /static/assets/guia_avatar.png, action: () > { closeProxyModal(); simulateProxyDemoTable(); } }, { target: #tutorial-proxy-status, title: Estado SSL, content: Mostra se tens o SSL válido e ativo., clone: { scale: 1.15, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > { closeProxyModal(); simulateProxyDemoTable(); } }, { target: #tutorial-proxy-cloud, title: Integração Cloud, content: Diz-te se o teu dominínio está a passar pela cloudflare ou não, se estiver a passar a imagem fica laranja, é obrigatório estar para a proxy., clone: { scale: 1.15, maxWidth: 320px }, avatar: /static/assets/guia_avatar.png, action: () > { closeProxyModal(); simulateProxyDemoTable(); } }, { target: #tutorial-proxy-actions, title: Ações, content: Reemitir SSL para quando estiver a expirar (a cada 90 dias), bloquear ou remover a proxy., clone: { scale: 1.15, maxWidth: 420px }, avatar: /static/assets/guia_avatar.png, action: () > { closeProxyModal(); simulateProxyDemoTable(); } } , users: { target: #new-user-btn, title: Novo utilizador, content: Abre o formulário para convidar novos membros e atribuir perfis de acesso., clone: { scale: 1.12, maxWidth: 280px }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(users); closeUserModal(); } }, { target: #user-modal, title: Visão geral, content: O modal agrupa campos de identificação à esquerda e permissões à direita., clone: { scale: 1.02, maxWidth: 560px, offsetY: -40 }, avatar: /static/assets/guia_avatar.png, action: () > { activateTab(users); openUserModal(false); } }, { target: #new-user, title: Identificação, content: Define o username utilizado no login., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #new-email, title: Email, content: Endereço que receberá notificações e servirá como contacto principal., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #new-password, title: Password inicial, content: Opcional para edições. Se vazio ao editar, mantém a password atual., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #new-role, title: Perfil de acesso, content: Escolhe entre utilizador, captain ou admin consoante o nível pretendido., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #new-limit, title: Limite de proxys, content: Define o máximo de proxies que este utilizador pode criar (0 ilimitado)., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #new-disabled, title: Estado, content: Ativa para suspender temporariamente o acesso deste utilizador sem o remover., clone: { scale: 1.08, maxWidth: 360px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #open-permissions, title: Menus permitidos, content: Abre o seletor de permissões e escolhe os módulos de acesso., clone: { scale: 1.05, maxWidth: 380px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) }, { target: #user-form-submit, title: Guardar, content: Finaliza a criação ou edição. O botão Cancelar fecha o modal sem gravar alterações., clone: { scale: 1.1, maxWidth: 320px }, avatar: /static/assets/guia_avatar.png, action: () > openUserModal(false) } }; let tutorialActive false; let tutorialGuideKey null; let tutorialStep 0; let TUTORIAL_STEPS ;let tutorialCleanup ;let tutorialCloneWrapper null; let tutorialProxySnapshot null; const tutorialOverlay document.getElementById(tutorial-overlay); const tutorialSpotlight document.getElementById(tutorial-spotlight); const tutorialTooltip document.getElementById(tutorial-tooltip); const tutorialSelectOverlay document.getElementById(tutorial-select-overlay); const tutorialSelectClose document.getElementById(tutorial-select-close); const tutorialSelectCancel document.getElementById(tutorial-select-cancel); const tutorialSelectOptions document.querySelectorAll(.tutorial-option); if (tutorialSelectClose) tutorialSelectClose.addEventListener(click, closeTutorialSelector); if (tutorialSelectCancel) tutorialSelectCancel.addEventListener(click, closeTutorialSelector); if (tutorialSelectOverlay) { tutorialSelectOverlay.addEventListener(click, (ev) > { if (ev.target tutorialSelectOverlay) closeTutorialSelector(); }); } tutorialSelectOptions.forEach(btn > { btn.addEventListener(click, () > { const guide btn.dataset.guide; closeTutorialSelector(); startTutorial(guide); }); });function registerTutorialCleanup(fn) { if (typeof fn ! function) return; tutorialCleanup.push(fn);}function runTutorialCleanup() { while (tutorialCleanup.length) { const fn tutorialCleanup.pop(); try { fn(); } catch (err) { console.error(err); } } if (tutorialCloneWrapper && tutorialCloneWrapper.parentNode) { tutorialCloneWrapper.parentNode.removeChild(tutorialCloneWrapper); } tutorialCloneWrapper null;}function renderTutorialClone(targetEl, opts {}) { if (!targetEl) return null; const wrapper document.createElement(div); wrapper.className tutorial-clone-wrapper; if (opts.maxWidth) wrapper.style.maxWidth opts.maxWidth; if (typeof opts.scale number) { wrapper.style.transform `translate(-50%, -50%) scale(${opts.scale})`; } if (typeof opts.offsetY number) { wrapper.style.top `calc(50% + ${opts.offsetY}px)`; } if (typeof opts.offsetX number) { wrapper.style.left `calc(50% + ${opts.offsetX}px)`; } const clone targetEl.cloneNode(true); clone.classList.add(tutorial-clone); wrapper.appendChild(clone); document.body.appendChild(wrapper); // Add mouse movement effects wrapper.addEventListener(mousemove, (e) > updateCloneGlow(e, wrapper)); wrapper.addEventListener(mouseleave, () > removeCloneGlow(wrapper)); // Add to cleanup registerTutorialCleanup(() > { wrapper.removeEventListener(mousemove, (e) > updateCloneGlow(e, wrapper)); wrapper.removeEventListener(mouseleave, () > removeCloneGlow(wrapper)); }); tutorialCloneWrapper wrapper; registerTutorialCleanup(() > { if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); tutorialCloneWrapper null; }); return wrapper;} function simulateProxyDemoTable() { if (!proxiesContainer) return; if (tutorialProxySnapshot null) { tutorialProxySnapshot proxiesContainer.innerHTML; } const demoTable ` table classtutorial-demo-table> thead> tr> th>Domínio/th> th>Owner/th> th>SSL/th> th>Cloudflare/th> th>Ações/th> /tr> /thead> tbody> tr idtutorial-proxy-row> td> div classdomain-cell> div classdomain-top>span classdomain-link>demo.caravela.app/span>span classtype-pill>Portal/span>/div> div classstatus-row> span classstatus-chip active idtutorial-proxy-status>Ativa/span> /div> /div> /td> td> div classinfo-stack> div classinfo-line>span classinfo-label>Owner/span>span classinfo-value>captain/span>/div> /div> /td> td> div classinfo-stack titleACTIVE>div classinfo-line>span classinfo-label> /span>span classinfo-value styledisplay:inline-flex;align-items:center;gap:.4rem;>span styledisplay:inline-block;width:10px;height:10px;border-radius:999px;background:#10b981;box-shadow:0 0 0 2px rgba(255,255,255,0.08);>/span>/span>/div>/div> /td> td> div classcloud-indicator active idtutorial-proxy-cloud>img src/static/assets/cloudflare-cloud.svg altCloudflare stylewidth:50px;height:50px;>/div> /td> td> div classactions idtutorial-proxy-actions> button typebutton classpill-btn>Reemitir SSL/button> button typebutton classpill-btn>Bloquear/button> button typebutton classpill-btn danger>Apagar/button> /div> /td> /tr> /tbody> /table> `; proxiesContainer.innerHTML demoTable; if (typeof bindProxyTableEvents function) { bindProxyTableEvents(proxiesContainer); } registerTutorialCleanup(() > { if (tutorialProxySnapshot ! null) { proxiesContainer.innerHTML tutorialProxySnapshot; tutorialProxySnapshot null; if (typeof bindProxyTableEvents function) { bindProxyTableEvents(proxiesContainer); } } }); } function openTutorialSelector() { tutorialSelectOverlay.classList.add(show); } function closeTutorialSelector() { tutorialSelectOverlay.classList.remove(show); } function activateTab(tab) { const btn document.querySelector(`.nav-btndata-tab${tab}`); if (btn && !btn.classList.contains(active)) { btn.click(); } } function startTutorial(guideKey) { if (!guideKey) { openTutorialSelector(); return; } const steps TUTORIAL_GUIDESguideKey; if (!steps || !steps.length) { showToast(Guia em preparação., danger); return; } closeTutorialSelector(); if (typeof closeProxyModal function) closeProxyModal(); if (typeof closeUserModal function) closeUserModal(); if (typeof closeTotpModal function) closeTotpModal(); tutorialGuideKey guideKey; TUTORIAL_STEPS steps; tutorialActive true; tutorialStep 0; document.body.classList.add(tutorial-mode); tutorialTooltip.style.display ; showTutorialStep(); } function stopTutorial(showToastMessage false) { tutorialActive false; tutorialGuideKey null; tutorialStep 0; TUTORIAL_STEPS ; document.body.classList.remove(tutorial-mode); runTutorialCleanup(); tutorialSpotlight.style.width 0px; tutorialSpotlight.style.height 0px; tutorialSpotlight.style.display none; tutorialTooltip.style.display ; closeTutorialSelector(); if (typeof closeProxyModal function) closeProxyModal(); if (typeof closeUserModal function) closeUserModal(); if (typeof closeTotpModal function) closeTotpModal(); if (showToastMessage) { showToast(Tutorial concluído!, success); } } function updateCloneGlow(e, wrapper) { if (!wrapper) return; const rect wrapper.getBoundingClientRect(); const x ((e.clientX - rect.left) / rect.width) * 100; const y ((e.clientY - rect.top) / rect.height) * 100; wrapper.style.setProperty(--mouse-x, x + %); wrapper.style.setProperty(--mouse-y, y + %); const clone wrapper.querySelector(.tutorial-clone); if (clone) clone.classList.add(active);}function removeCloneGlow(wrapper) { if (!wrapper) return; const clone wrapper.querySelector(.tutorial-clone); if (clone) clone.classList.remove(active);}function showTutorialStep() { if (!tutorialActive || tutorialStep 0 || tutorialStep > TUTORIAL_STEPS.length) { stopTutorial(false); return; } runTutorialCleanup(); const step TUTORIAL_STEPStutorialStep; const tutorialStepTitleEl document.getElementById(tutorial-step-title); const tutorialStepContentEl document.getElementById(tutorial-step-content); const tutorialControls document.querySelector(#tutorial-tooltip > divstyle*display: flex); // Select the div containing buttons // Clear previous content tutorialTooltip.innerHTML ; if (step.avatar) { const avatarContainer document.createElement(div); avatarContainer.className tutorial-avatar-container; const avatarImg document.createElement(img); avatarImg.className tutorial-avatar; avatarImg.src step.avatar; avatarImg.alt Tutorial Avatar; const speechBubble document.createElement(div); speechBubble.className tutorial-speech-bubble; const titleInBubble document.createElement(h3); titleInBubble.className tutorial-step-title; titleInBubble.textContent step.title; const contentInBubble document.createElement(div); contentInBubble.className tutorial-step-content; contentInBubble.innerHTML step.content || ; speechBubble.appendChild(titleInBubble); speechBubble.appendChild(contentInBubble); avatarContainer.appendChild(avatarImg); avatarContainer.appendChild(speechBubble); tutorialTooltip.appendChild(avatarContainer); } else { const titleEl document.createElement(h3); titleEl.id tutorial-step-title; titleEl.className tutorial-step-title; titleEl.textContent step.title; const contentEl document.createElement(div); contentEl.id tutorial-step-content; contentEl.className tutorial-step-content; contentEl.innerHTML step.content || ; tutorialTooltip.appendChild(titleEl); tutorialTooltip.appendChild(contentEl); } // Re-append controls if (tutorialControls) { tutorialTooltip.appendChild(tutorialControls); } const prevBtn document.getElementById(tutorial-prev); const nextBtn document.getElementById(tutorial-next); prevBtn.style.display tutorialStep > 0 ? block : none; nextBtn.textContent tutorialStep TUTORIAL_STEPS.length - 1 ? Concluído ✓ : Próximo →; if (typeof step.action function) step.action(); const targetEl step.target ? document.querySelector(step.target) : null; let elementToFocus targetEl; let rect; if (step.clone && targetEl) { const clonedWrapper renderTutorialClone(targetEl, step.clone); elementToFocus clonedWrapper; // Position tooltip relative to the cloned wrapper tutorialSpotlight.style.display none; // Hide spotlight for cloned elements rect clonedWrapper.getBoundingClientRect(); // Adjust tooltip position to be relative to the cloned element tutorialTooltip.style.left rect.right + 24 + px; tutorialTooltip.style.top rect.top + px; } else if (targetEl) { try { targetEl.scrollIntoView({ behavior: smooth, block: center, inline: center }); } catch (_) {} rect targetEl.getBoundingClientRect(); tutorialSpotlight.style.display block; tutorialSpotlight.style.top Math.max(8, rect.top - 6) + px; tutorialSpotlight.style.left Math.max(8, rect.left - 6) + px; tutorialSpotlight.style.width rect.width + 12 + px; tutorialSpotlight.style.height rect.height + 12 + px; tutorialSpotlight.style.boxShadow 0 0 0 9999px rgba(9, 13, 28, 0.75); tutorialTooltip.style.left rect.right + 24 + px; tutorialTooltip.style.top rect.top + px; } else { tutorialSpotlight.style.display none; positionTooltipCenter(); } if (elementToFocus) { const tooltipRect tutorialTooltip.getBoundingClientRect(); let left parseFloat(tutorialTooltip.style.left); let top parseFloat(tutorialTooltip.style.top); // Adjust tooltip position to stay within viewport if (left + tooltipRect.width > window.innerWidth - 20) { left Math.max(20, rect.left - tooltipRect.width - 24); } if (left 20) { left 20; } if (top 20) { top 20; } if (top + tooltipRect.height > window.innerHeight - 20) { top Math.max(20, window.innerHeight - tooltipRect.height - 20); } tutorialTooltip.style.left left + px; tutorialTooltip.style.top top + px; tutorialTooltip.style.transform none; // Ensure no central transform if positioned relative to element } tutorialTooltip.style.display ; } function positionTooltipCenter() { tutorialTooltip.style.left 50%; tutorialTooltip.style.top 50%; tutorialTooltip.style.transform translate(-50%, -50%); } function nextTutorialStep() { if (tutorialStep TUTORIAL_STEPS.length - 1) { tutorialStep++; showTutorialStep(); } else { stopTutorial(true); openTutorialSelector(); } } function prevTutorialStep() { if (tutorialStep > 0) { tutorialStep--; showTutorialStep(); } } // Toast and loading logic (unchanged) const toastEl document.getElementById(toast); const loadingEl document.getElementById(loading); const proxiesContainer document.getElementById(proxies-container); const permBadgesContainer document.getElementById(perm-badges-container); const tutorialBtn document.getElementById(tutorial-btn); document.getElementById(tutorial-next).addEventListener(click, nextTutorialStep); document.getElementById(tutorial-prev).addEventListener(click, prevTutorialStep); document.getElementById(tutorial-skip).addEventListener(click, stopTutorial); document.addEventListener(keydown, (e) > { if (!tutorialActive) return; if (e.key Escape) stopTutorial(); else if (e.key ArrowRight) nextTutorialStep(); else if (e.key ArrowLeft) prevTutorialStep(); }); async function handleLoginSuccess({ user, authHeader, credentials, remember null, silentToast false }) { try { state.currentUser user; state.authHeader authHeader; const rememberCheckbox document.getElementById(login-remember); if (typeof remember boolean) { state.rememberLogin remember; if (rememberCheckbox) rememberCheckbox.checked remember; if (remember && credentials && credentials.username && credentials.password) { localStorage.setItem(STORAGE_KEY, JSON.stringify({ username: credentials.username, password: credentials.password, remember: true })); } else if (!remember) { localStorage.removeItem(STORAGE_KEY); } } else if (rememberCheckbox) { rememberCheckbox.checked !!state.rememberLogin; } sessionStorage.setItem(caravelaAuthHeader, authHeader); sessionStorage.setItem(caravelaRemember, state.rememberLogin ? true : false); setTotpStateFromUser(user); updateTotpButtonState(); const totpInput document.getElementById(login-totp); if (totpInput) totpInput.value ; const loginModal document.getElementById(login-modal); if (loginModal) loginModal.classList.add(hidden); const appRoot document.getElementById(app-root); if (appRoot) appRoot.style.display block; document.body.classList.remove(booting); if (!silentToast) { showToast(Sessão iniciada! Bem-vindo, + user.username, success); } await afterLoginInit(); } catch (err) { showToast(err.message || Erro ao inicializar a interface., danger); throw err; } } async function attemptAutoLogin() { const sessionAuth sessionStorage.getItem(caravelaAuthHeader); if (sessionAuth) { try { showLoading(); const res await fetch(/api/me, { headers: { Authorization: sessionAuth } }); if (!res.ok) { let payload null; try { payload await res.json(); } catch (err) { payload null; } const message payload && (payload.detail || payload.error); throw new Error(message || Sessão expirada.); } const user await res.json(); const rememberRaw sessionStorage.getItem(caravelaRemember); const rememberValue rememberRaw true ? true : (rememberRaw false ? false : null); await handleLoginSuccess({ user, authHeader: sessionAuth, credentials: null, remember: rememberValue, silentToast: true }); showToast(Sessão restaurada para + user.username + ., success); hideLoading(); document.body.classList.remove(booting); return; } catch (err) { const message err.message || Sessão expirada.; showToast(message, danger); sessionStorage.removeItem(caravelaAuthHeader); sessionStorage.removeItem(caravelaRemember); hideLoading(); } } const stored localStorage.getItem(STORAGE_KEY); if (!stored) { document.body.classList.remove(booting); const appRoot document.getElementById(app-root); if (appRoot) appRoot.style.display none; const loginModal document.getElementById(login-modal); if (loginModal) loginModal.classList.remove(hidden); return; } try { const creds JSON.parse(stored); if (!creds || !creds.username || !creds.password || !creds.remember) { localStorage.removeItem(STORAGE_KEY); document.body.classList.remove(booting); return; } showLoading(); const authHeader Basic + btoa(creds.username + : + creds.password); const res await fetch(/api/me, { headers: { Authorization: authHeader } }); if (!res.ok) { let payload null; try { payload await res.json(); } catch (err) { payload null; } const message payload && (payload.detail || payload.error); throw new Error(message || Sessão expirada.); } const user await res.json(); await handleLoginSuccess({ user, authHeader, credentials: creds, remember: true, silentToast: true }); showToast(Sessão restaurada para + user.username + ., success); } catch (err) { const message err.message || Sessão expirada.; showToast(message, danger); localStorage.removeItem(STORAGE_KEY); } finally { hideLoading(); document.body.classList.remove(booting); const appRoot document.getElementById(app-root); if (appRoot) appRoot.style.display none; const loginModal document.getElementById(login-modal); if (loginModal) loginModal.classList.remove(hidden); } } // Login logic document.getElementById(login-form).addEventListener(submit, async function(e) { e.preventDefault(); const username document.getElementById(login-username).value.trim(); const password document.getElementById(login-password).value; const remember document.getElementById(login-remember).checked; const totpCode document.getElementById(login-totp).value.trim(); if (!username || !password) { showToast(Preencha utilizador e palavra-passe., danger); return; } showLoading(); try { const authHeader Basic + btoa(username + : + password); const headers { Authorization: authHeader }; if (totpCode) headersX-TOTP totpCode; const res await fetch(/api/me, { headers }); let payload null; if (!res.ok) { try { payload await res.json(); } catch (err) { payload null; } const message payload && (payload.detail || payload.error); throw new Error(message || Credenciais inválidas ou utilizador desativado.); } const user await res.json(); await handleLoginSuccess({ user, authHeader, credentials: { username, password }, remember, silentToast: false }); } catch (err) { const message err.message || Erro ao autenticar.; showToast(message, danger); if (message.toLowerCase().includes(totp)) { const totpInput document.getElementById(login-totp); if (totpInput) totpInput.focus(); } } finally { hideLoading(); } }); // Fallbacks for missing UI functions function showLoading() { state.loadingCount (state.loadingCount || 0) + 1; loadingEl.classList.add(show); } function hideLoading() { state.loadingCount Math.max(0, (state.loadingCount || 0) - 1); if (state.loadingCount 0) { loadingEl.classList.remove(show); } } function showToast(msg, type) { toastEl.textContent msg; toastEl.className toast show; if (type danger) toastEl.style.background #f87171; else if (type success) toastEl.style.background #34d399; else toastEl.style.background rgba(15,23,42,0.95); setTimeout(() > { toastEl.className toast; }, 3000); } // Patch all form submissions to use JS fetch with auth document.addEventListener(DOMContentLoaded, function() { // User create/edit form const userForm document.getElementById(user-create-form); if (userForm) { userForm.addEventListener(submit, async function(e) { e.preventDefault(); const isEdit !!state.editingUsername; const usernameValue document.getElementById(new-user).value.trim(); const passwordValue document.getElementById(new-password).value; if (!isEdit && !usernameValue) { showToast(Indica um nome de utilizador., danger); return; } if (!isEdit && !passwordValue) { showToast(Define uma palavra-passe inicial., danger); return; } showLoading(); try { const formData new FormData(userForm); const keys getAssignableMenuKeys(); let snapshot null; if (typeof tempUserPerms object && tempUserPerms) { snapshot tempUserPerms; } else { const permCheckboxes document.querySelectorAll(#permission-menu inputtypecheckbox); if (permCheckboxes.length) { snapshot {}; permCheckboxes.forEach(checkbox > { const key checkbox.name.replace(permission_, ); snapshotkey checkbox.checked; }); } else { if (state.editingUsername) { const u state.users.find(x > x.username state.editingUsername); snapshot (u && u.permissions) ? { ...u.permissions } : defaultPermissionsForRole(document.getElementById(new-role)?.value || user); } else { snapshot defaultPermissionsForRole(document.getElementById(new-role)?.value || user); } } } keys.forEach(key > formData.set(permission_ + key, !!snapshotkey)); formData.set(disabled, document.getElementById(new-disabled).checked ? true : false); if (isEdit) { formData.delete(username); if (!formData.get(password)) formData.delete(password); } const endpoint isEdit ? `/api/users/${encodeURIComponent(state.editingUsername)}` : /api/users; const method isEdit ? PUT : POST; const res await fetch(endpoint, { method, body: formData }); if (!res.ok) { let payload {}; try { payload await res.json(); } catch (_) {} throw new Error(payload.detail || payload.error || (isEdit ? Erro ao atualizar utilizador : Erro ao criar utilizador)); } showToast(isEdit ? Utilizador atualizado! : Utilizador criado!, success); closeUserModal(); await renderUsersTab(true); populateOwnerDropdowns(); } catch (err) { showToast(err.message || Operação falhou., danger); } finally { hideLoading(); } }); } // Proxy create form const proxyForm document.getElementById(create-form); if (proxyForm) { const ownerFieldDiv document.getElementById(owner-field); const ownerSelect document.getElementById(owner); if (ownerFieldDiv) ownerFieldDiv.style.display none; // removed proxy env hint under selector proxyForm.addEventListener(submit, async function(e) { e.preventDefault(); const overlay document.getElementById(ssl-progress-overlay); const titleEl document.getElementById(ssl-progress-title); const msgEl document.getElementById(ssl-progress-message); const helpEl document.getElementById(ssl-progress-help); const closeBtn document.getElementById(ssl-progress-close); const setStep (title, msg) > { if (titleEl) titleEl.textContent title; if (msgEl) msgEl.textContent msg; }; if (overlay) { helpEl.style.display none; if (closeBtn) closeBtn.style.display none; setStep(A pedir SSL…, A pedir SSL a Lets Encrypt…); overlay.style.display flex; } else { showLoading(); } try { const formData new FormData(proxyForm); // If owner field is visible, add owner and email if (ownerSelect && ownerSelect.value) { formData.append(owner, ownerSelect.value); const selectedOpt ownerSelect.optionsownerSelect.selectedIndex; if (selectedOpt && selectedOpt.dataset.email) { formData.append(owner_email, selectedOpt.dataset.email); } } // Small UX pause to show overlay and then update step await new Promise(r > setTimeout(r, 250)); if (overlay) setStep(À espera da Let\s Encrypt…, Aguarda conclusão do pedido...); const res await fetch(/api/proxies, { method: POST, body: formData }); const raw await res.text(); let data null; try { data JSON.parse(raw); } catch (_) { data null; } if (!res.ok || (data && data.ok false) || !data) { const selectedType (document.getElementById(proxy_type)?.value || ).trim(); const msg (data && (data.detail || data.error)) || (raw || ).trim() || Erro ao criar proxy; if (/Missing env PROXY_/i.test(msg) && selectedType) { const hint `Falta configurar a variável ${mkEnv(selectedType)} no container. Define o upstream e tenta novamente.`; const el document.getElementById(proxy-error); if (el) { el.style.display ; el.textContent hint; } if (overlay) { setStep(Falhou!, hint); helpEl.style.display ; if (closeBtn) closeBtn.style.display ; } throw new Error(hint); } if (/Owner email not configured/i.test(msg)) { const hint O email do proprietário não está definido. Edita o utilizador e define um email válido.; const el document.getElementById(proxy-error); if (el) { el.style.display ; el.textContent hint; } if (overlay) { setStep(Falhou!, hint); helpEl.style.display ; if (closeBtn) closeBtn.style.display ; } throw new Error(hint); } // If nginx -t output is present, show it to help fix template errors if (/nginx/i.test(msg) || /Config\/Reload error/i.test(msg)) { const el document.getElementById(proxy-error); if (el) { el.style.display ; el.textContent msg; } if (overlay) { setStep(Falhou!, msg); helpEl.style.display ; if (closeBtn) closeBtn.style.display ; } throw new Error(msg); } const el document.getElementById(proxy-error); if (el) { el.style.display ; el.textContent msg; } if (overlay) { setStep(Falhou!, msg); helpEl.style.display ; if (closeBtn) closeBtn.style.display ; } throw new Error(msg); } let successMsg; if (data.ssl_provider cloudflare) { successMsg `Proxy criada para ${data.domain}. Certificado externo Cloudflare detectado. Ativa a nuvem laranja no DNS e usa Verificar Cloudflare para confirmar.`; } else if (data.ssl_provider lets-encrypt) { successMsg `Proxy criada para ${data.domain}. SSL Lets Encrypt ${data.ssl_status active ? emitido : pendente}.`; } else { successMsg `Proxy criada para ${data.domain}. SSL ${data.ssl_status || pendente}.`; } if (overlay) setStep(Sucesso!, successMsg); showToast(successMsg, success); closeProxyModal(); await loadProxies({ silent: true }); // hide overlay after small delay if (overlay) { await new Promise(r > setTimeout(r, 1800)); overlay.style.display none; } // Open confirm modal with detailed instructions await openConfirmModal( Ativar Cloudflare, `Para ativar a proxy ${data.domain}, siga estes passos: 1. Acesse o painel da Cloudflare2. Localize o domínio na lista3. Ative o proxy (nuvem laranja) para o registro DNS4. Aguarde a propagação (pode levar alguns minutos)5. Use o botão Verificar Cloudflare para confirmar a ativaçãoA proxy só funcionará após a ativação do Cloudflare.` ); } catch (err) { showToast(err.message, danger); } finally { const overlay document.getElementById(ssl-progress-overlay); if (!overlay || overlay.style.display none) hideLoading(); } }); } // Close button for SSL overlay (only visible on failure) const sslClose document.getElementById(ssl-progress-close); if (sslClose) sslClose.addEventListener(click, () > { const overlay document.getElementById(ssl-progress-overlay); if (overlay) overlay.style.display none; }); const emulatorForm document.getElementById(emulator-form); if (emulatorForm) { emulatorForm.addEventListener(submit, async function(e) { e.preventDefault(); await submitEmulatorForm(emulatorForm); }); } const promptForm document.getElementById(prompt-modal-form); if (promptForm) { promptForm.addEventListener(submit, function(e) { e.preventDefault(); const value document.getElementById(prompt-modal-input).value; closePromptModal(value); }); } const promptCancel document.getElementById(prompt-modal-cancel); if (promptCancel) promptCancel.addEventListener(click, () > closePromptModal(null)); const promptClose document.getElementById(prompt-modal-close); if (promptClose) promptClose.addEventListener(click, () > closePromptModal(null)); const confirmOk document.getElementById(confirm-modal-ok); if (confirmOk) confirmOk.addEventListener(click, () > closeConfirmModal(true)); const confirmCancel document.getElementById(confirm-modal-cancel); if (confirmCancel) confirmCancel.addEventListener(click, () > closeConfirmModal(false)); const confirmClose document.getElementById(confirm-modal-close); if (confirmClose) confirmClose.addEventListener(click, () > closeConfirmModal(false)); }); // Permissions mini-modal logic const permissionsOverlay document.getElementById(permissions-modal-overlay); const permissionsClose document.getElementById(permissions-modal-close); const permissionsOpenBtn document.getElementById(open-permissions); const permissionsApply document.getElementById(permissions-apply); const permissionsCancel document.getElementById(permissions-cancel); const permissionList document.getElementById(permission-menu); const selectedPermsPreview null; // preview removed let tempUserPerms null; // persist choices while user modal is open // Compute base permissions (editing user or role defaults) function getBasePermissionsForForm() { if (state.editingUsername) { const u state.users.find(x > x.username state.editingUsername); if (u && u.permissions) return { ...u.permissions }; } const roleSel document.getElementById(new-role); const roleVal roleSel ? roleSel.value : user; return defaultPermissionsForRole(roleVal); } function getCurrentPermissionState() { if (tempUserPerms && typeof tempUserPerms object) return { ...tempUserPerms }; return getBasePermissionsForForm(); } function openPermissionsModal() { if (!permissionList) return; // Always rebuild based on current context permissionList.innerHTML ; const base getCurrentPermissionState(); const keys getAssignableMenuKeys(); const frag document.createDocumentFragment(); keys.forEach(key > { const id `permission_${key}`; const label key.charAt(0).toUpperCase() + key.slice(1); const wrapper document.createElement(label); wrapper.style.display flex; wrapper.style.alignItems center; wrapper.style.gap .5rem; const cb document.createElement(input); cb.type checkbox; cb.name id; cb.id id; cb.checked !!basekey; cb.addEventListener(change, () > { const k id.replace(permission_, ); if (!tempUserPerms) tempUserPerms { ...base }; tempUserPermsk cb.checked; updateSelectedPermsPreview(); }); const span document.createElement(span); span.textContent label; wrapper.appendChild(cb); wrapper.appendChild(span); frag.appendChild(wrapper); }); permissionList.appendChild(frag); permissionsOverlay.classList.add(show); } function closePermissionsModal() { permissionsOverlay.classList.remove(show); } function updateSelectedPermsPreview() { /* preview removed */ } if (permissionsOpenBtn) permissionsOpenBtn.addEventListener(click, openPermissionsModal); if (permissionsClose) permissionsClose.addEventListener(click, closePermissionsModal); if (permissionsCancel) permissionsCancel.addEventListener(click, closePermissionsModal); if (permissionsOverlay) permissionsOverlay.addEventListener(click, (ev) > { if (ev.target permissionsOverlay) closePermissionsModal(); }); if (permissionsApply) permissionsApply.addEventListener(click, () > { // Persist current checkbox state const checks document.querySelectorAll(#permission-menu inputtypecheckbox); const snap {}; checks.forEach(cb > { const k cb.name.replace(permission_, ); snapk cb.checked; }); tempUserPerms snap; // preview removed closePermissionsModal(); }); const roleSelectEl document.getElementById(new-role); if (roleSelectEl) { roleSelectEl.addEventListener(change, () > { // Reset temporary perms to defaults on role change tempUserPerms null; if (permissionsOverlay && permissionsOverlay.classList.contains(show)) openPermissionsModal(); }); } document.addEventListener(DOMContentLoaded, attemptAutoLogin); // Promise-based modal dialogs let modalPromiseResolve null; let confirmPromiseResolve null; function openPromptModal(title, label, defaultValue , inputType text) { return new Promise((resolve) > { document.getElementById(prompt-modal-title).textContent title; document.getElementById(prompt-modal-label).textContent label; const input document.getElementById(prompt-modal-input); input.type inputType; input.value defaultValue; input.focus(); input.select(); modalPromiseResolve resolve; promptModal.classList.add(show); }); } function closePromptModal(value null) { promptModal.classList.remove(show); if (modalPromiseResolve) { modalPromiseResolve(value); modalPromiseResolve null; } } function openConfirmModal(title, message, options {}) { return new Promise((resolve) > { document.getElementById(confirm-modal-title).textContent title; const messageEl document.getElementById(confirm-modal-message); if (options.allowHTML) { messageEl.innerHTML message; messageEl.dataset.html true; } else { messageEl.textContent message; delete messageEl.dataset.html; } confirmPromiseResolve resolve; confirmModal.classList.add(show); }); } function closeConfirmModal(confirmed false) { confirmModal.classList.remove(show); const messageEl document.getElementById(confirm-modal-message); if (messageEl.dataset.html true) { messageEl.textContent ; delete messageEl.dataset.html; } if (confirmPromiseResolve) { confirmPromiseResolve(confirmed); confirmPromiseResolve null; } } /script>/body>/html>
View on OTX
|
View on ThreatMiner
Please enable JavaScript to view the
comments powered by Disqus.
Data with thanks to
AlienVault OTX
,
VirusTotal
,
Malwr
and
others
. [
Sitemap
]