diff --git a/.env.example b/.env.example index 04b120c..0037729 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,9 @@ PTERO_API_KEY=change_me # Encryption key for sensitive data — Generate with: openssl rand -hex 32 ENCRYPTION_KEY=change_me_to_a_random_hex_string -# Cloudflare Turnstile -TURNSTILE_SECRET=change_me +# Cap CAPTCHA (self-hosted alternative to Turnstile) +CAP_ENDPOINT=https://cap.zero-host.org/f6c8171b08/ +CAP_SECRET=change_me # Session & Cookie COOKIE_SECRET=change_me_to_a_random_string diff --git a/config/cap.js b/config/cap.js new file mode 100644 index 0000000..cb79d0f --- /dev/null +++ b/config/cap.js @@ -0,0 +1,35 @@ +const CAP_SECRET = process.env.CAP_SECRET; +const CAP_ENDPOINT = process.env.CAP_ENDPOINT; + +if (!CAP_SECRET) { + console.error('Missing CAP_SECRET environment variable'); +} + +if (!CAP_ENDPOINT) { + console.error('Missing CAP_ENDPOINT environment variable'); +} + +async function fetchWithTimeout(url, options = {}, timeout = 10000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export async function verifyCap(token) { + if (!token) return false; + try { + const res = await fetchWithTimeout(`${CAP_ENDPOINT}siteverify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret: CAP_SECRET, response: token }), + }); + const data = await res.json(); + return data.success === true; + } catch { + return false; + } +} diff --git a/config/turnstile.js b/config/turnstile.js deleted file mode 100644 index e470372..0000000 --- a/config/turnstile.js +++ /dev/null @@ -1,30 +0,0 @@ -const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET; - -if (!TURNSTILE_SECRET) { - console.error('Missing TURNSTILE_SECRET environment variable'); -} - -async function fetchWithTimeout(url, options = {}, timeout = 10000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -export async function verifyTurnstile(token) { - if (!token) return false; - try { - const res = await fetchWithTimeout('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token }), - }); - const data = await res.json(); - return data.success === true; - } catch { - return false; - } -} diff --git a/package.json b/package.json index d8631e5..7b80abf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.0.0", + "version": "0.9.7", "private": true, "type": "module", "scripts": { diff --git a/public/css/style.css b/public/css/style.css index ea0d304..f0f148c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -5,24 +5,24 @@ } :root { - --bg-primary: #060911; - --bg-secondary: #0d1117; - --bg-card: #0f1623; - --bg-card-hover: #141d2e; - --bg-sidebar: #0a0e1a; - --border: rgba(99, 102, 241, 0.15); - --border-hover: rgba(99, 102, 241, 0.4); - --accent-1: #6366f1; - --accent-2: #8b5cf6; - --accent-3: #a78bfa; - --accent-cyan: #22d3ee; - --accent-green: #10b981; + --bg-primary: #000; + --bg-secondary: #1c1917; + --bg-card: #1c1917; + --bg-card-hover: #292524; + --bg-sidebar: #0d0d0d; + --border: rgba(238, 129, 50, 0.15); + --border-hover: rgba(238, 129, 50, 0.3); + --accent-1: #ee8132; + --accent-2: #ee8132; + --accent-3: #ee8132; + --accent-cyan: #06b6d4; + --accent-green: #059669; --accent-red: #ef4444; --accent-orange: #f59e0b; - --text-primary: #f1f5f9; - --text-secondary: #94a3b8; - --text-muted: #475569; - --glow-primary: rgba(99, 102, 241, 0.35); + --text-primary: #f5f5f4; + --text-secondary: #a8a29e; + --text-muted: #78716c; + --glow-primary: rgba(238, 129, 50, 0.15); --radius-sm: 8px; --radius-md: 14px; --radius-lg: 20px; @@ -31,9 +31,15 @@ --sidebar-w: 260px; } -html, body { +html { height: 100%; } + +body, body * { + font-family: 'Schibsted Grotesk', system-ui, sans-serif; + font-weight: 400; +} + +body { height: 100%; - font-family: 'Inter', system-ui, sans-serif; background: var(--bg-primary); color: var(--text-primary); line-height: 1.6; @@ -53,19 +59,34 @@ a:hover { color: var(--accent-1); } display: flex; align-items: center; justify-content: center; - background: var(--bg-primary); + background: #000; position: relative; padding: 24px; } .auth-page::before { content: ''; - position: absolute; + position: fixed; inset: 0; - background: - radial-gradient(ellipse 70% 55% at 50% -5%, rgba(99, 102, 241, 0.12) 0%, transparent 65%), - linear-gradient(180deg, transparent 40%, var(--bg-primary) 100%); pointer-events: none; + z-index: 0; + background-image: + linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); +} + +.auth-page::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 512px 512px; } .auth-card { @@ -134,7 +155,7 @@ a:hover { color: var(--accent-1); } border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); - font-family: 'Inter', sans-serif; + font-family: 'Schibsted Grotesk', sans-serif; font-size: 0.95rem; transition: all var(--transition); outline: none; @@ -142,7 +163,7 @@ a:hover { color: var(--accent-1); } .form-group input:focus, .form-group select:focus { border-color: var(--accent-1); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); } .form-group input::placeholder { @@ -170,7 +191,7 @@ a:hover { color: var(--accent-1); } border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); - font-family: 'Inter', sans-serif; + font-family: 'Schibsted Grotesk', sans-serif; font-size: 0.95rem; cursor: pointer; display: flex; @@ -189,7 +210,7 @@ a:hover { color: var(--accent-1); } .custom-select-trigger:focus, .custom-select.open .custom-select-trigger { border-color: var(--accent-1); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + box-shadow: 0 0 0 3px rgba(238, 129, 50, 0.15); } .custom-select-arrow { @@ -222,16 +243,18 @@ a:hover { color: var(--accent-1); } } .custom-select-option { - padding: 10px 16px; + padding: 10px 16px 10px 20px; font-size: 0.9rem; color: var(--text-secondary); cursor: pointer; transition: all var(--transition); + border-left: 2px solid transparent; } .custom-select-option:hover { - background: rgba(99, 102, 241, 0.08); + background: rgba(238, 129, 50, 0.08); color: var(--text-primary); + border-left-color: var(--accent-1); } .custom-select-option:first-child { @@ -243,16 +266,20 @@ a:hover { color: var(--accent-1); } } .custom-select-category { - padding: 8px 16px 4px; - font-size: 0.7rem; - font-weight: 700; + padding: 14px 16px 6px; + font-size: 0.65rem; + font-weight: 800; text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); + letter-spacing: 0.12em; + color: var(--accent-1); cursor: default; + border-top: 1px solid rgba(238, 129, 50, 0.12); + margin-top: 6px; } .custom-select-category:first-child { + border-top: none; + margin-top: 0; padding-top: 12px; } @@ -261,6 +288,21 @@ a:hover { color: var(--accent-1); } gap: 12px; } +cap-widget { + display: block !important; + width: 100% !important; + min-width: 100% !important; + --cap-background: #1c1917; + --cap-color: #f5f5f4; + --cap-border-color: rgba(238, 129, 50, 0.15); + --cap-checkbox-border-radius: 8px; + --cap-border-radius: 8px; + --cap-checkbox-background: transparent; + --cap-spinner-color: #ee8132; + --cap-spinner-background-color: rgba(238, 129, 50, 0.1); + --cap-widget-width: 100%; +} + .form-row .form-group { flex: 1; } @@ -270,7 +312,7 @@ a:hover { color: var(--accent-1); } align-items: center; justify-content: center; gap: 8px; - font-family: 'Inter', sans-serif; + font-family: 'Schibsted Grotesk', sans-serif; font-size: 0.9rem; font-weight: 600; border-radius: var(--radius-md); @@ -283,15 +325,15 @@ a:hover { color: var(--accent-1); } } .btn-primary { - background: linear-gradient(135deg, var(--accent-1), var(--accent-2)); + background: linear-gradient(180deg, #433b32 0%, #29241f 100%); color: #fff; - box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4); + border: 1px solid rgba(255, 237, 217, 0.2); } .btn-primary:hover { color: #fff; transform: translateY(-2px); - box-shadow: 0 8px 32px rgba(99, 102, 241, 0.55); + border-color: rgba(238, 129, 50, 0.4); } .btn-primary:disabled { @@ -309,7 +351,7 @@ a:hover { color: var(--accent-1); } .btn-ghost:hover { color: var(--text-primary); border-color: var(--border-hover); - background: rgba(99, 102, 241, 0.06); + background: rgba(238, 129, 50, 0.06); } .btn-danger { @@ -377,6 +419,32 @@ a:hover { color: var(--accent-1); } .dashboard-layout { display: flex; min-height: 100vh; + position: relative; +} + +.dashboard-layout::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(238, 129, 50, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 129, 50, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 30%, transparent 80%); +} + +.dashboard-layout::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 512px 512px; } .sidebar { @@ -393,6 +461,11 @@ a:hover { color: var(--accent-1); } transition: transform var(--transition); } +.main-content { + position: relative; + z-index: 1; +} + .sidebar-header { padding: 20px 20px 16px; border-bottom: 1px solid var(--border); @@ -453,12 +526,12 @@ a:hover { color: var(--accent-1); } .nav-item:hover { color: var(--text-primary); - background: rgba(99, 102, 241, 0.08); + background: rgba(238, 129, 50, 0.08); } .nav-item.active { color: var(--accent-1); - background: rgba(99, 102, 241, 0.12); + background: rgba(238, 129, 50, 0.12); } .nav-item svg { @@ -483,7 +556,7 @@ a:hover { color: var(--accent-1); } width: 32px; height: 32px; border-radius: 50%; - background: linear-gradient(135deg, var(--accent-1), var(--accent-2)); + background: var(--accent-1); display: flex; align-items: center; justify-content: center; @@ -597,7 +670,7 @@ a:hover { color: var(--accent-1); } justify-content: center; margin-bottom: 16px; color: var(--accent-1); - background: rgba(99, 102, 241, 0.12); + background: rgba(238, 129, 50, 0.12); } .stat-value { @@ -733,7 +806,7 @@ tbody tr:last-child td { } tbody tr:hover { - background: rgba(99, 102, 241, 0.04); + background: rgba(238, 129, 50, 0.04); } /* ===== TABS ===== */ @@ -866,7 +939,7 @@ tbody tr:hover { width: 56px; height: 56px; border-radius: 50%; - background: rgba(99, 102, 241, 0.1); + background: rgba(238, 129, 50, 0.1); display: flex; align-items: center; justify-content: center; @@ -1254,13 +1327,57 @@ tbody tr:hover { gap: 10px; } -.consent-group input[type="checkbox"] { - width: 18px; - height: 18px; - margin-top: 2px; - accent-color: var(--accent-1); +.custom-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; cursor: pointer; flex-shrink: 0; + margin-top: 2px; +} + +.custom-checkbox input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.custom-checkbox .checkmark { + width: 20px; + height: 20px; + border: 2px solid var(--text-secondary); + border-radius: 4px; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-sizing: border-box; +} + +.custom-checkbox input[type="checkbox"]:checked ~ .checkmark { + background: var(--accent-1); + border-color: var(--accent-1); +} + +.custom-checkbox input[type="checkbox"]:checked ~ .checkmark::after { + content: ''; + display: block; + width: 5px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-top: -1px; +} + +.custom-checkbox input[type="checkbox"]:focus-visible ~ .checkmark { + outline: 2px solid var(--accent-1); + outline-offset: 2px; } .consent-group label { @@ -1275,4 +1392,60 @@ tbody tr:hover { text-decoration: underline; } +.cap-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.5); + animation: capFadeIn 0.2s ease; +} + +.cap-modal-overlay.cap-modal-fadeout { + animation: capFadeOut 0.3s ease forwards; +} + +.cap-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: capScaleIn 0.2s ease; +} + +.cap-modal cap-widget { + display: block !important; + width: 300px !important; + min-width: unset !important; +} + +.cap-modal-overlay.cap-modal-fadeout .cap-modal { + animation: capScaleOut 0.3s ease forwards; +} + +@keyframes capFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes capFadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes capScaleIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes capScaleOut { + from { transform: scale(1); opacity: 1; } + to { transform: scale(0.9); opacity: 0; } +} + diff --git a/public/index.html b/public/index.html index 7c1c6d6..251a4d3 100644 --- a/public/index.html +++ b/public/index.html @@ -7,9 +7,9 @@ - + - + diff --git a/public/js/app.js b/public/js/app.js index d11f187..0e87ff3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -24,7 +24,7 @@ function renderCookieBanner() { banner.innerHTML = ` @@ -150,28 +150,46 @@ function hideError(form) { if (errorEl) errorEl.classList.remove('show'); } -const turnstileWidgets = {}; - -function initTurnstile(selector) { - const el = typeof selector === 'string' ? document.querySelector(selector) : selector; - if (!el) return; - const tryRender = () => { - if (typeof turnstile !== 'undefined') { - if (el.querySelector('iframe')) return; - const widgetId = turnstile.render(el, { sitekey: '0x4AAAAAADjivxHTaDDdYR8W', theme: 'dark' }); - turnstileWidgets[selector] = widgetId; - } else { - setTimeout(tryRender, 200); + + +function showCapModal() { + return new Promise((resolve) => { + if (!customElements.get('cap-widget')) { + resolve(''); + return; } - }; - tryRender(); -} -function resetTurnstile(selector) { - const id = turnstileWidgets[selector]; - if (typeof turnstile !== 'undefined' && id !== undefined) { - turnstile.reset(id); - } + const capApiEndpoint = 'https://cap.zero-host.org/f6c8171b08/'; + + const overlay = document.createElement('div'); + overlay.className = 'cap-modal-overlay'; + + const modal = document.createElement('div'); + modal.className = 'cap-modal'; + + const widget = document.createElement('cap-widget'); + widget.setAttribute('data-cap-api-endpoint', capApiEndpoint); + widget.setAttribute('theme', 'dark'); + + modal.appendChild(widget); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => { + const check = setInterval(() => { + const hiddenInput = widget.querySelector('[name="cap-token"]'); + const token = widget.token || (hiddenInput && hiddenInput.value) || ''; + if (token) { + clearInterval(check); + overlay.classList.add('cap-modal-fadeout'); + setTimeout(() => { + overlay.remove(); + resolve(token); + }, 300); + } + }, 200); + }, 100); + }); } // ===== AUTH PAGES ===== @@ -196,7 +214,6 @@ function renderLoginPage() { -
@@ -210,7 +227,6 @@ function renderLoginPage() { `; $('#login-form').addEventListener('submit', handleLogin); - initTurnstile('#login-turnstile'); $('#go-register').addEventListener('click', (e) => { e.preventDefault(); renderRegisterPage(); @@ -243,12 +259,14 @@ function renderRegisterPage() { -
@@ -262,7 +280,6 @@ function renderRegisterPage() { `; $('#register-form').addEventListener('submit', handleRegister); - initTurnstile('#register-turnstile'); $('#go-login').addEventListener('click', (e) => { e.preventDefault(); renderLoginPage(); @@ -277,13 +294,13 @@ async function handleLogin(e) { btn.innerHTML = ' Signing in...'; try { - const turnstileToken = document.querySelector('#login-turnstile')?.querySelector('[name="cf-turnstile-response"]')?.value || ''; + const capToken = await showCapModal(); const data = await api('/auth/login', { method: 'POST', body: JSON.stringify({ email: $('#login-email').value, password: $('#login-password').value, - cfTurnstile: turnstileToken, + capToken, }), }); state.token = data.token; @@ -293,7 +310,6 @@ async function handleLogin(e) { renderDashboard(); } catch (err) { showError(e.target, err.message); - resetTurnstile('#login-turnstile'); } finally { btn.disabled = false; btn.innerHTML = 'Sign In'; @@ -315,14 +331,14 @@ async function handleRegister(e) { btn.innerHTML = ' Creating...'; try { - const turnstileToken = document.querySelector('#register-turnstile')?.querySelector('[name="cf-turnstile-response"]')?.value || ''; + const capToken = await showCapModal(); const data = await api('/auth/register', { method: 'POST', body: JSON.stringify({ email: $('#reg-email').value, username: $('#reg-username').value, password: $('#reg-password').value, - cfTurnstile: turnstileToken, + capToken, rgpdConsent: true, }), }); @@ -333,7 +349,6 @@ async function handleRegister(e) { renderDashboard(); } catch (err) { showError(e.target, err.message); - resetTurnstile('#register-turnstile'); } finally { btn.disabled = false; btn.innerHTML = 'Create Account'; @@ -398,7 +413,7 @@ async function renderDashboard() {
-
v0.9.6 BETA
+
v0.9.7 BETA
@@ -755,7 +770,7 @@ async function renderCreateServer() {
-
+
Default resources
512 MB RAM @@ -763,7 +778,9 @@ async function renderCreateServer() { 3 GB Disk
-
+
+ +