|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="utf-8" /> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 6 | +<meta name="robots" content="noindex, nofollow" /> |
| 7 | +<title>Admin · CumulusVPN</title> |
| 8 | +<meta name="theme-color" content="#070B11" /> |
| 9 | +<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" /> |
| 10 | +<style> |
| 11 | + @font-face { font-family:"Inter"; font-weight:400; font-display:swap; src:url("/fonts/inter-400.woff2") format("woff2"); } |
| 12 | + @font-face { font-family:"Inter"; font-weight:500; font-display:swap; src:url("/fonts/inter-500.woff2") format("woff2"); } |
| 13 | + @font-face { font-family:"Inter"; font-weight:600; font-display:swap; src:url("/fonts/inter-600.woff2") format("woff2"); } |
| 14 | + @font-face { font-family:"Inter"; font-weight:700; font-display:swap; src:url("/fonts/inter-700.woff2") format("woff2"); } |
| 15 | + :root { |
| 16 | + --bg:#070B11; --surface:#10171F; --surface-2:#161F2A; --ink:#EAF1F8; --ink-2:#A6B6C6; |
| 17 | + --ink-3:#6C7E90; --line:#1E2A37; --cyan:#34E4DA; --amber:#F5B23D; --red:#F26D6D; --green:#34D399; |
| 18 | + --mono: ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace; |
| 19 | + --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; |
| 20 | + } |
| 21 | + @media (prefers-color-scheme: light) { |
| 22 | + :root { --bg:#F5F8FB; --surface:#FFFFFF; --surface-2:#F0F4F8; --ink:#0C1420; --ink-2:#46586B; |
| 23 | + --ink-3:#7B8D9E; --line:#DCE5EE; --cyan:#0FB9AE; --amber:#C77F17; --red:#C4443A; --green:#1F9D6B; } |
| 24 | + } |
| 25 | + * { box-sizing:border-box; } |
| 26 | + body { margin:0; background:var(--bg); color:var(--ink); font-family:var(--sans); |
| 27 | + -webkit-font-smoothing:antialiased; line-height:1.5; min-height:100vh; } |
| 28 | + .wrap { max-width:560px; margin:0 auto; padding:48px 24px 80px; } |
| 29 | + .brand { display:flex; align-items:center; gap:11px; font-weight:700; letter-spacing:-.02em; font-size:18px; margin-bottom:6px; } |
| 30 | + .mark { width:26px; height:26px; } |
| 31 | + .sub { color:var(--ink-3); font-family:var(--mono); font-size:12px; letter-spacing:.06em; text-transform:uppercase; margin-bottom:28px; } |
| 32 | + .card { background:var(--surface); border:1px solid var(--line); border-radius:16px; padding:24px; margin-bottom:18px; |
| 33 | + box-shadow:0 30px 64px -40px rgba(3,8,16,.55); } |
| 34 | + h2 { font-size:16px; letter-spacing:-.01em; margin:0 0 4px; } |
| 35 | + .hint { color:var(--ink-3); font-size:13px; margin:0 0 16px; } |
| 36 | + label.fld { display:block; font-size:12px; color:var(--ink-3); text-transform:uppercase; letter-spacing:.06em; margin-bottom:7px; } |
| 37 | + input[type=password] { width:100%; font:inherit; font-size:15px; background:var(--surface-2); color:var(--ink); |
| 38 | + border:1px solid var(--line); border-radius:10px; padding:12px 14px; } |
| 39 | + input[type=password]:focus { outline:none; border-color:var(--cyan); box-shadow:0 0 0 3px color-mix(in srgb,var(--cyan) 30%,transparent); } |
| 40 | + .btn { font:inherit; font-weight:600; font-size:15px; border:1px solid transparent; border-radius:11px; padding:12px 18px; |
| 41 | + cursor:pointer; display:inline-flex; align-items:center; gap:8px; } |
| 42 | + .btn.primary { background:var(--cyan); color:#04201d; } |
| 43 | + .btn.primary:hover { filter:brightness(1.06); } |
| 44 | + .btn.ghost { background:transparent; border-color:var(--line); color:var(--ink-2); } |
| 45 | + .btn.ghost:hover { border-color:var(--ink-3); color:var(--ink); } |
| 46 | + .btn.sm { padding:8px 14px; font-size:13px; } |
| 47 | + /* dashboard section scaffolding */ |
| 48 | + .dash-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; } |
| 49 | + .sec-title { font-size:13px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--ink-2); } |
| 50 | + .row { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:16px 0; border-top:1px solid var(--line); } |
| 51 | + .row:first-of-type { border-top:0; } |
| 52 | + .row .k { font-weight:600; font-size:15px; } |
| 53 | + .row .k small { display:block; color:var(--ink-3); font-weight:400; font-size:12.5px; margin-top:2px; } |
| 54 | + /* toggle */ |
| 55 | + .sw { position:relative; width:46px; height:27px; flex-shrink:0; } |
| 56 | + .sw input { position:absolute; opacity:0; width:100%; height:100%; margin:0; cursor:pointer; } |
| 57 | + .sw .track { position:absolute; inset:0; background:var(--surface-2); border:1px solid var(--line); border-radius:99px; transition:.18s; } |
| 58 | + .sw .knob { position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:var(--ink-3); transition:.18s; } |
| 59 | + .sw input:checked ~ .track { background:color-mix(in srgb,var(--amber) 26%,transparent); border-color:var(--amber); } |
| 60 | + .sw input:checked ~ .knob { left:22px; background:var(--amber); } |
| 61 | + .sw input:focus-visible ~ .track { box-shadow:0 0 0 3px color-mix(in srgb,var(--cyan) 30%,transparent); } |
| 62 | + .warn { border:1px solid color-mix(in srgb,var(--amber) 45%,var(--line)); background:color-mix(in srgb,var(--amber) 12%,transparent); |
| 63 | + border-radius:12px; padding:14px 16px; font-size:13px; line-height:1.55; color:var(--ink); margin-bottom:20px; } |
| 64 | + .warn b { color:var(--amber); } |
| 65 | + .foot { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:22px; } |
| 66 | + .updated { color:var(--ink-3); font-family:var(--mono); font-size:11.5px; } |
| 67 | + .err { color:var(--red); font-size:13px; margin-top:12px; min-height:1em; } |
| 68 | + #toast { position:fixed; left:50%; bottom:28px; transform:translateX(-50%) translateY(20px); opacity:0; pointer-events:none; |
| 69 | + background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; |
| 70 | + box-shadow:0 20px 50px -20px rgba(0,0,0,.5); transition:.2s; } |
| 71 | + #toast.show { opacity:1; transform:translateX(-50%) translateY(0); } |
| 72 | + #toast.ok { border-color:color-mix(in srgb,var(--green) 50%,var(--line)); } |
| 73 | + #toast.bad { border-color:color-mix(in srgb,var(--red) 50%,var(--line)); } |
| 74 | + [hidden] { display:none !important; } |
| 75 | +</style> |
| 76 | +</head> |
| 77 | +<body> |
| 78 | +<div class="wrap"> |
| 79 | + <div class="brand"> |
| 80 | + <svg class="mark" viewBox="0 0 32 32" fill="none" aria-hidden="true"> |
| 81 | + <path d="M9 21h13a5 5 0 0 0 .6-9.96A7 7 0 0 0 8.5 12 4.5 4.5 0 0 0 9 21Z" fill="currentColor" opacity=".18"/> |
| 82 | + <path d="M9 21h13a5 5 0 0 0 .6-9.96A7 7 0 0 0 8.5 12 4.5 4.5 0 0 0 9 21Z" stroke="var(--cyan)" stroke-width="1.7"/> |
| 83 | + <path d="M14 15l-2.5 4h3l-1 4 4.5-6h-3l1.5-2.6z" fill="var(--cyan)"/> |
| 84 | + </svg> |
| 85 | + CumulusVPN |
| 86 | + </div> |
| 87 | + <div class="sub">Admin · internal</div> |
| 88 | + |
| 89 | + <!-- LOGIN --> |
| 90 | + <div class="card" id="login" hidden> |
| 91 | + <h2>Sign in</h2> |
| 92 | + <p class="hint">Enter the admin password to continue.</p> |
| 93 | + <label class="fld" for="pw">Admin password</label> |
| 94 | + <input type="password" id="pw" autocomplete="current-password" placeholder="••••••••••" /> |
| 95 | + <div class="foot"> |
| 96 | + <span></span> |
| 97 | + <button class="btn primary" id="signin">Sign in</button> |
| 98 | + </div> |
| 99 | + <div class="err" id="login-err"></div> |
| 100 | + </div> |
| 101 | + |
| 102 | + <!-- DASHBOARD (shown after auth) --> |
| 103 | + <div id="editor" hidden> |
| 104 | + <div class="dash-head"> |
| 105 | + <div class="sec-title">Feature flags</div> |
| 106 | + <button class="btn ghost sm" id="signout">Sign out</button> |
| 107 | + </div> |
| 108 | + <div class="card"> |
| 109 | + <div class="warn"> |
| 110 | + <b>⚠ Store-compliance:</b> turning <code>inAppUpgrade</code> <b>on</b> shows the in-app FLUX |
| 111 | + purchase UI (QR + wallet). <b>Do not enable it for a build that is under, or has passed, |
| 112 | + App Store / Google Play review</b> — a post-review behaviour change is a policy |
| 113 | + violation. Direct-APK / web / testing builds only. |
| 114 | + </div> |
| 115 | + |
| 116 | + <h2>inAppUpgrade</h2> |
| 117 | + <p class="hint">In-app FLUX upgrade flow, per platform. Off = store-compliant "manage on web".</p> |
| 118 | + |
| 119 | + <div class="row"> |
| 120 | + <div class="k">Android <small>com.cumulusvpn.app</small></div> |
| 121 | + <label class="sw"><input type="checkbox" id="t-android" /><span class="track"></span><span class="knob"></span></label> |
| 122 | + </div> |
| 123 | + <div class="row"> |
| 124 | + <div class="k">iOS <small>com.cumulusvpn.app (App Store)</small></div> |
| 125 | + <label class="sw"><input type="checkbox" id="t-ios" /><span class="track"></span><span class="knob"></span></label> |
| 126 | + </div> |
| 127 | + |
| 128 | + <div class="foot"> |
| 129 | + <span class="updated" id="updated">—</span> |
| 130 | + <button class="btn primary" id="save">Save</button> |
| 131 | + </div> |
| 132 | + <div class="err" id="save-err"></div> |
| 133 | + </div> |
| 134 | + </div> |
| 135 | +</div> |
| 136 | + |
| 137 | +<div id="toast"></div> |
| 138 | + |
| 139 | +<script> |
| 140 | + var $ = function (id) { return document.getElementById(id); }; |
| 141 | + var TOKEN_KEY = 'cvpn-admin-token'; |
| 142 | + var token = ''; |
| 143 | + try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) {} |
| 144 | + |
| 145 | + function toast(msg, ok) { |
| 146 | + var t = $('toast'); |
| 147 | + t.textContent = msg; |
| 148 | + t.className = 'show ' + (ok ? 'ok' : 'bad'); |
| 149 | + clearTimeout(toast._t); |
| 150 | + toast._t = setTimeout(function () { t.className = ''; }, 2600); |
| 151 | + } |
| 152 | + |
| 153 | + function verify(tok) { |
| 154 | + return fetch('/api/admin/verify', { |
| 155 | + method: 'POST', headers: { authorization: 'Bearer ' + tok }, |
| 156 | + }).then(function (r) { return r.ok; }).catch(function () { return false; }); |
| 157 | + } |
| 158 | + |
| 159 | + function loadFlags() { |
| 160 | + return fetch('/api/flags').then(function (r) { return r.json(); }).then(function (f) { |
| 161 | + var u = (f && f.inAppUpgrade) || {}; |
| 162 | + $('t-android').checked = u.android === true; |
| 163 | + $('t-ios').checked = u.ios === true; |
| 164 | + $('updated').textContent = f && f.updatedAt |
| 165 | + ? 'Last updated ' + new Date(f.updatedAt).toLocaleString() |
| 166 | + : 'Never updated (serving defaults)'; |
| 167 | + }); |
| 168 | + } |
| 169 | + |
| 170 | + function showEditor() { $('login').hidden = true; $('editor').hidden = false; loadFlags(); } |
| 171 | + function showLogin() { $('editor').hidden = true; $('login').hidden = false; $('pw').focus(); } |
| 172 | + |
| 173 | + function signOut() { |
| 174 | + token = ''; |
| 175 | + try { sessionStorage.removeItem(TOKEN_KEY); } catch (e) {} |
| 176 | + showLogin(); |
| 177 | + } |
| 178 | + |
| 179 | + function doLogin() { |
| 180 | + var pw = $('pw').value; |
| 181 | + $('login-err').textContent = ''; |
| 182 | + if (!pw) return; |
| 183 | + verify(pw).then(function (ok) { |
| 184 | + if (!ok) { $('login-err').textContent = 'Wrong password.'; return; } |
| 185 | + token = pw; |
| 186 | + try { sessionStorage.setItem(TOKEN_KEY, pw); } catch (e) {} |
| 187 | + $('pw').value = ''; |
| 188 | + showEditor(); |
| 189 | + }); |
| 190 | + } |
| 191 | + |
| 192 | + function save() { |
| 193 | + $('save-err').textContent = ''; |
| 194 | + var body = { inAppUpgrade: { android: $('t-android').checked, ios: $('t-ios').checked } }; |
| 195 | + fetch('/api/flags', { |
| 196 | + method: 'POST', |
| 197 | + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, |
| 198 | + body: JSON.stringify(body), |
| 199 | + }).then(function (r) { |
| 200 | + if (r.status === 401) { signOut(); toast('Session expired — sign in again', false); return null; } |
| 201 | + if (!r.ok) { toast('Save failed (' + r.status + ')', false); return null; } |
| 202 | + return r.json(); |
| 203 | + }).then(function (f) { |
| 204 | + if (!f) return; |
| 205 | + $('updated').textContent = 'Last updated ' + new Date(f.updatedAt).toLocaleString(); |
| 206 | + toast('Saved', true); |
| 207 | + }).catch(function () { toast('Network error', false); }); |
| 208 | + } |
| 209 | + |
| 210 | + $('signin').addEventListener('click', doLogin); |
| 211 | + $('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); |
| 212 | + $('save').addEventListener('click', save); |
| 213 | + $('signout').addEventListener('click', signOut); |
| 214 | + |
| 215 | + // init: resume a stored session if the token still verifies |
| 216 | + (function () { |
| 217 | + if (!token) { showLogin(); return; } |
| 218 | + verify(token).then(function (ok) { |
| 219 | + if (ok) showEditor(); else signOut(); |
| 220 | + }); |
| 221 | + })(); |
| 222 | +</script> |
| 223 | +</body> |
| 224 | +</html> |
0 commit comments