lib/pages.js
Rama main · réplica de lectura del repositorio original.
function pinPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<meta name="mobile-web-app-capable" content="yes">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<link rel="apple-touch-icon" href="/apple-touch-icon.png">' +
'<title>Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<h1 id="greeting"></h1>' +
'<div class="sub">Enter your PIN to continue</div>' +
pinBoxesHtml +
'<div class="err" id="err"></div>' +
'<script>' +
'(function(){' +
'var h=document.getElementById("greeting");' +
'var visited=localStorage.getItem("clay_visited");' +
'if(visited){h.textContent="Welcome back"}' +
'else{h.textContent="Welcome to Clay";localStorage.setItem("clay_visited","1")}' +
'})();' +
pinBoxScript +
'var err=document.getElementById("err");' +
'function submitPin(){' +
'var pin=document.getElementById("pin").value;' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'fetch("/auth",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({pin:pin})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.reload();return}' +
'if(d.locked){for(var i=0;i<boxes.length;i++)boxes[i].disabled=true;' +
'err.textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";' +
'setTimeout(function(){for(var i=0;i<boxes.length;i++)boxes[i].disabled=false;' +
'err.textContent="";resetPinBoxes()},d.retryAfter*1000);return}' +
'var msg="Wrong PIN";if(typeof d.attemptsLeft==="number"&&d.attemptsLeft<=3)msg+=" ("+d.attemptsLeft+" left)";' +
'err.textContent=msg;resetPinBoxes()})' +
'.catch(function(){err.textContent="Connection error"})}' +
'initPinBoxes("pin-boxes","pin",submitPin);' +
'</script></div></body></html>';
}
function setupPageHtml(httpsUrl, httpUrl, hasCert, lanMode) {
return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<title>Setup - Clay Studio</title>
<style>
:root{--s-bg:#282a36;--s-text:#f8f8f2;--s-accent:#ffb86c;--s-muted:#6272a4;--s-border:#44475a;--s-dimmer:#6272a4;--s-success:#50fa7b;--s-accent-15:rgba(255,184,108,0.15);--s-success-10:rgba(80,250,123,0.1);--s-success-15:rgba(80,250,123,0.15);--s-accent-06:rgba(255,184,108,0.06);--s-muted-06:rgba(98,114,164,0.06);--s-muted-15:rgba(98,114,164,0.15)}
@media(prefers-color-scheme:light){:root{--s-bg:#FAFAFA;--s-text:#5C6166;--s-accent:#FA8D3E;--s-muted:#A0A6AC;--s-border:#D2D4D8;--s-dimmer:#8A9199;--s-success:#6CBF49;--s-accent-15:rgba(250,141,62,0.15);--s-success-10:rgba(108,191,73,0.1);--s-success-15:rgba(108,191,73,0.15);--s-accent-06:rgba(250,141,62,0.06);--s-muted-06:rgba(160,166,172,0.06);--s-muted-15:rgba(160,166,172,0.15)}}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--s-bg);color:var(--s-text);font-family:system-ui,-apple-system,sans-serif;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top,0) 20px 40px}
.c{max-width:480px;width:100%;padding-top:40px}
h1{color:var(--s-accent);font-size:22px;margin:0 0 4px;text-align:center}
.subtitle{text-align:center;color:var(--s-muted);font-size:13px;margin-bottom:28px}
/* Steps indicator */
.steps-bar{display:flex;gap:6px;margin-bottom:32px}
.steps-bar .pip{flex:1;height:3px;border-radius:2px;background:var(--s-border);transition:background 0.3s}
.steps-bar .pip.done{background:var(--s-success)}
.steps-bar .pip.active{background:var(--s-accent)}
/* Step card */
.step-card{display:none;animation:fadeIn 0.25s ease}
.step-card.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.step-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--s-accent);font-weight:600;margin-bottom:8px}
.step-title{font-size:18px;font-weight:600;margin-bottom:6px}
.step-desc{font-size:14px;line-height:1.6;color:var(--s-muted);margin-bottom:20px}
.instruction{display:flex;gap:12px;margin-bottom:16px}
.inst-num{width:24px;height:24px;border-radius:50%;background:var(--s-accent-15);color:var(--s-accent);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex-shrink:0;margin-top:1px}
.inst-text{font-size:14px;line-height:1.6}
.inst-text .note{font-size:12px;color:var(--s-dimmer);margin-top:4px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:var(--s-accent);color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;font-size:14px;margin:4px 0;border:none;cursor:pointer;font-family:inherit;transition:opacity 0.15s}
.btn:hover{opacity:0.9}
.btn.outline{background:transparent;border:1.5px solid var(--s-border);color:var(--s-text)}
.btn.outline:hover{border-color:var(--s-dimmer)}
.btn.success{background:var(--s-success)}
.btn:disabled{opacity:0.4;cursor:default}
.btn-row{display:flex;gap:8px;margin-top:20px}
.btn-row .btn{flex:1}
.check-status{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:10px;font-size:13px;margin:16px 0}
.check-status.ok{background:var(--s-success-10);color:var(--s-success);border:1px solid var(--s-success-15)}
.check-status.warn{background:var(--s-accent-06);border:1px solid var(--s-accent-15);color:var(--s-accent)}
.check-status.pending{background:var(--s-muted-06);border:1px solid var(--s-muted-15);color:var(--s-muted)}
.platform-ios,.platform-android,.platform-desktop{display:none}
.done-card{text-align:center;padding:40px 0}
.done-icon{font-size:48px;margin-bottom:16px}
.done-title{font-size:20px;font-weight:600;margin-bottom:8px}
.done-desc{font-size:14px;color:var(--s-muted);margin-bottom:24px}
.skip-link{display:block;text-align:center;color:var(--s-dimmer);font-size:13px;text-decoration:none;margin-top:12px;cursor:pointer;border:none;background:none;font-family:inherit}
.skip-link:hover{color:var(--s-muted)}
</style></head><body>
<div class="c">
<h1>Clay</h1>
<p class="subtitle">Setup your device for the best experience</p>
<div class="steps-bar" id="steps-bar"></div>
<!-- Step: Tailscale -->
<div class="step-card" id="step-tailscale">
<div class="step-label">Step <span class="step-cur">1</span> of <span class="step-total">4</span></div>
<div class="step-title">Connect via Tailscale</div>
<div class="step-desc">Tailscale creates a private VPN so you can access Clay from anywhere. It needs to be installed on <b>both</b> the server (the machine running Clay) and this device.</div>
<div class="instruction"><div class="inst-num">1</div>
<div class="inst-text"><b>Server:</b> Install Tailscale on the machine running Clay.
<div class="note">If you are viewing this page, the server likely already has Tailscale. You can verify by checking its 100.x.x.x IP.</div>
</div>
</div>
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text"><b>This device:</b> Install Tailscale here and sign in with the same account.
<div class="platform-ios" style="margin-top:8px">
<a class="btn" href="https://apps.apple.com/app/tailscale/id1470499037" target="_blank" rel="noopener">App Store</a>
</div>
<div class="platform-android" style="margin-top:8px">
<a class="btn" href="https://play.google.com/store/apps/details?id=com.tailscale.ipn" target="_blank" rel="noopener">Google Play</a>
</div>
<div class="platform-desktop" style="margin-top:8px">
<a class="btn" href="https://tailscale.com/download" target="_blank" rel="noopener">Download Tailscale</a>
</div>
</div>
</div>
<div class="instruction"><div class="inst-num">3</div>
<div class="inst-text">Once both devices are on Tailscale, open the relay using the server's Tailscale IP.
<div class="note" id="tailscale-url-hint"></div>
</div>
</div>
<div id="ts-status" class="check-status pending">Checking connection...</div>
<div class="btn-row">
<button class="btn" id="ts-next" onclick="nextStep()" disabled>Verifying...</button>
</div>
</div>
<!-- Step: Certificate -->
<div class="step-card" id="step-cert">
<div class="step-label">Step <span class="step-cur">1</span> of <span class="step-total">3</span></div>
<div class="step-title">Install certificate</div>
<div class="step-desc">Encrypt all traffic between this device and the relay. The certificate is generated locally and does not grant any additional access.</div>
<div class="instruction"><div class="inst-num">1</div>
<div class="inst-text">Download the certificate.<br>
<a class="btn" href="/ca/download" style="margin-top:8px">Download Certificate</a>
</div>
</div>
<div class="platform-ios">
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">Open <b>Settings</b> and tap the <b>Profile Downloaded</b> banner to install.
<div class="note">If the banner is gone: Settings > General > VPN & Device Management</div>
</div>
</div>
<div class="instruction"><div class="inst-num">3</div>
<div class="inst-text">Go to <b>Settings > General > About > Certificate Trust Settings</b> and enable full trust.</div>
</div>
</div>
<div class="platform-android">
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">Open the downloaded file, or go to <b>Settings > Security > Install a certificate > CA certificate</b>.
<div class="note">Path may vary by device. Search "certificate" in Settings if needed.</div>
</div>
</div>
</div>
<div class="platform-desktop">
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">The certificate should be trusted automatically via mkcert. If your browser still shows a warning, run <code>mkcert -install</code> on the host machine.</div>
</div>
</div>
<div id="cert-status" class="check-status pending">Checking HTTPS connection...</div>
<div class="btn-row">
<button class="btn" id="cert-retry" onclick="checkHttps()" style="display:none">Retry</button>
<button class="btn" id="cert-next" onclick="nextStep()" disabled>Verifying...</button>
</div>
</div>
<!-- Step: Install PWA -->
<div class="step-card" id="step-pwa">
<div class="step-label">Step <span class="step-cur">2</span> of <span class="step-total">3</span></div>
<div class="step-title">Add to Home Screen</div>
<div class="step-desc">Install Clay as an app for quick access and a full-screen experience.</div>
<div class="platform-ios">
<div class="check-status warn">On iOS, push notifications only work from the installed app. This step is required.</div>
<div id="ios-not-safari" class="check-status warn" style="display:none">You must use <b>Safari</b> to install. Open this page in Safari first.</div>
<div id="ios-safari-steps">
<div class="instruction"><div class="inst-num">1</div>
<div class="inst-text">Tap the <b>Share</b> button <svg width="18" height="18" viewBox="0 0 17.695 26.475" style="vertical-align:middle;margin:0 2px"><g fill="currentColor"><path d="M17.334 10.762v9.746c0 2.012-1.025 3.027-3.066 3.027H3.066C1.026 23.535 0 22.52 0 20.508v-9.746C0 8.75 1.025 7.734 3.066 7.734h2.94v1.573h-2.92c-.977 0-1.514.527-1.514 1.543v9.57c0 1.015.537 1.543 1.514 1.543h11.152c.967 0 1.524-.527 1.524-1.543v-9.57c0-1.016-.557-1.543-1.524-1.543h-2.91V7.734h2.94c2.04 0 3.066 1.016 3.066 3.028Z"/><path d="M8.662 15.889c.42 0 .781-.352.781-.762V5.097l-.058-1.464.654.693 1.484 1.582a.698.698 0 0 0 .528.235c.4 0 .713-.293.713-.694 0-.205-.088-.361-.235-.508l-3.3-3.183c-.196-.196-.362-.264-.567-.264-.195 0-.361.069-.566.264L4.795 4.94a.681.681 0 0 0-.225.508c0 .4.293.694.703.694.186 0 .4-.079.538-.235l1.474-1.582.664-.693-.058 1.465v10.029c0 .41.351.762.771.762Z"/></g></svg> at the bottom of the Safari toolbar.
<div class="note" id="ios-ipad-hint" style="display:none">On iPad, the Share button is in the top toolbar.</div>
</div>
</div>
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">Scroll down in the share sheet and tap <b>Add to Home Screen</b> <svg width="18" height="18" viewBox="0 0 25 25" style="vertical-align:middle;margin:0 2px"><g fill="currentColor"><path d="m23.40492,1.60784c-1.32504,-1.32504 -3.19052,-1.56912 -5.59644,-1.56912l-10.65243,0c-2.33622,0 -4.2017,0.24408 -5.5267,1.56912c-1.32504,1.34243 -1.56911,3.17306 -1.56911,5.50924l0,10.5827c0,2.40596 0.22665,4.254 1.55165,5.57902c1.34246,1.32501 3.19052,1.5691 5.59647,1.5691l10.60013,0c2.40592,0 4.2714,-0.24408 5.59644,-1.5691c1.325,-1.34245 1.55166,-3.17306 1.55166,-5.57902l0,-10.51293c0,-2.40596 -0.22666,-4.25401 -1.55166,-5.57901zm-0.38355,5.21289l0,11.24518c0,1.51681 -0.20924,2.94643 -1.02865,3.78327c-0.83683,0.83685 -2.30134,1.0635 -3.81815,1.0635l-11.33234,0c-1.51681,0 -2.96386,-0.22665 -3.80073,-1.0635c-0.83683,-0.83684 -1.04607,-2.26646 -1.04607,-3.78327l0,-11.19288c0,-1.5517 0.20924,-3.01617 1.02865,-3.85304c0.83687,-0.83683 2.31876,-1.04607 3.87042,-1.04607l11.28007,0c1.51681,0 2.98132,0.22666 3.81815,1.06353c0.81941,0.81941 1.02865,2.26645 1.02865,3.78327zm-10.53039,12.08205c0.64506,0 1.02861,-0.43586 1.02861,-1.13326l0,-4.34117l4.53294,0c0.66252,0 1.13326,-0.36613 1.13326,-0.99376c0,-0.64506 -0.43586,-1.02861 -1.13326,-1.02861l-4.53294,0l0,-4.53294c0,-0.6974 -0.38355,-1.13326 -1.02861,-1.13326c-0.62763,0 -0.99376,0.45332 -0.99376,1.13326l0,4.53294l-4.51552,0c-0.69737,0 -1.15069,0.38355 -1.15069,1.02861c0,0.62763 0.48817,0.99376 1.15069,0.99376l4.51552,0l0,4.34117c0,0.66252 0.36613,1.13326 0.99376,1.13326z"/></g></svg></div>
</div>
<div class="instruction"><div class="inst-num">3</div>
<div class="inst-text">Tap <b>Add</b> in the top right corner to confirm.</div>
</div>
</div>
</div>
<div class="platform-android">
<div class="instruction"><div class="inst-num">1</div>
<div class="inst-text">Tap the <b>three dots menu</b> <svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:middle;margin:0 2px"><circle cx="12" cy="4" r="2.5" fill="currentColor"/><circle cx="12" cy="12" r="2.5" fill="currentColor"/><circle cx="12" cy="20" r="2.5" fill="currentColor"/></svg> in the top right corner of Chrome.</div>
</div>
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">Tap <b>Install app</b> or <b>Add to Home screen</b>.
<div class="note">If you don't see it, try <b>Open in Chrome</b> first if using another browser.</div>
</div>
</div>
<div class="instruction"><div class="inst-num">3</div>
<div class="inst-text">Tap <b>Install</b> in the confirmation dialog.</div>
</div>
</div>
<div class="platform-desktop">
<div class="instruction"><div class="inst-num">1</div>
<div class="inst-text">Look for the <b>install icon</b> in the address bar (a monitor with a down arrow).</div>
</div>
<div class="instruction"><div class="inst-num">2</div>
<div class="inst-text">Click it and then click <b>Install</b> to confirm.
<div class="note">If there is no icon, go to <b>Menu > Install Clay</b> or <b>Menu > Save and Share > Install</b>.</div>
</div>
</div>
</div>
<div id="pwa-status" class="check-status pending">After installing, open Clay from your home screen to continue setup.</div>
<button class="skip-link" id="pwa-skip" onclick="nextStep()" style="display:none">Skip for now</button>
</div>
<!-- Step 3: Push Notifications -->
<div class="step-card" id="step-push">
<div class="step-label">Step <span class="step-cur">3</span> of <span class="step-total">3</span></div>
<div class="step-title">Enable notifications</div>
<div class="step-desc">Get alerted on your phone when Claude finishes a response, even when the app is in the background.</div>
<div id="push-needs-https" class="check-status warn" style="display:none">Push notifications require HTTPS. Complete the certificate step first.</div>
<button class="btn" id="push-enable-btn" onclick="enablePush()" style="width:100%">Enable Push Notifications</button>
<div id="push-status" class="check-status pending" style="display:none"></div>
<div class="btn-row">
<button class="btn" id="push-next" onclick="nextStep()" style="display:none;width:100%">Finish</button>
</div>
</div>
<!-- Done -->
<div class="step-card" id="step-done">
<div class="done-card">
<div class="done-icon">✓</div>
<div class="done-title">All set!</div>
<div class="done-desc">Your device is configured. You can change these settings anytime from the app.</div>
<a class="btn" id="done-link" href="${httpsUrl}">Open Clay</a>
</div>
</div>
</div>
<script>
var httpsUrl = ${JSON.stringify(httpsUrl)};
var httpUrl = ${JSON.stringify(httpUrl)};
var hasCert = ${hasCert ? 'true' : 'false'};
var lanMode = ${lanMode ? 'true' : 'false'};
var isHttps = location.protocol === "https:";
var ua = navigator.userAgent;
var isIOS = /iPhone|iPad|iPod/.test(ua);
var isAndroid = /Android/i.test(ua);
var isStandalone = window.matchMedia("(display-mode:standalone)").matches || navigator.standalone;
var isIPad = /iPad/.test(ua) || (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1);
var isSafari = isIOS && /Safari/.test(ua) && !/CriOS|FxiOS|OPiOS|EdgiOS/.test(ua);
// Platform visibility
var platformClass = isIOS ? "platform-ios" : isAndroid ? "platform-android" : "platform-desktop";
var els = document.querySelectorAll("." + platformClass);
for (var i = 0; i < els.length; i++) els[i].style.display = "block";
// iOS: Safari check and iPad hint
if (isIOS) {
if (!isSafari) {
var warn = document.getElementById("ios-not-safari");
var safariSteps = document.getElementById("ios-safari-steps");
if (warn) warn.style.display = "flex";
if (safariSteps) safariSteps.style.display = "none";
}
if (isIPad) {
var hint = document.getElementById("ios-ipad-hint");
if (hint) hint.style.display = "block";
}
}
// Tailscale detection
var isTailscale = /^100\./.test(location.hostname);
var isLocal = location.hostname === "localhost" || location.hostname === "127.0.0.1";
// Detect push subscription, then build steps
function detectPush() {
if (!("serviceWorker" in navigator) || (!isHttps && !isLocal)) return Promise.resolve(false);
// If no SW is registered yet, don't wait for .ready (it never resolves)
if (!navigator.serviceWorker.controller) return Promise.resolve(false);
return navigator.serviceWorker.ready
.then(function(reg) { return reg.pushManager.getSubscription(); })
.then(function(sub) { return !!sub; })
.catch(function() { return false; });
}
var steps = [];
var currentStep = 0;
var bar = document.getElementById("steps-bar");
var curEls = document.querySelectorAll(".step-cur");
var totalEls = document.querySelectorAll(".step-total");
// Step offset: when continuing from browser setup (PWA was installed), carry over step count
var stepOffset = 0;
if (isStandalone && localStorage.getItem("setup-pending")) {
stepOffset = parseInt(localStorage.getItem("setup-pending"), 10) || 0;
}
function buildSteps(hasPushSub) {
steps = [];
if (!isTailscale && !isLocal && !lanMode) steps.push("tailscale");
if (hasCert && !isHttps) steps.push("cert");
if (isAndroid) {
// Android: push first (works in browser), then PWA as optional
if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
if (!isStandalone) steps.push("pwa");
} else {
// iOS: PWA required for push, so install first
if (!isStandalone) steps.push("pwa");
if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
}
steps.push("done");
// Trigger HTTPS check now that steps are built
if (steps.indexOf("cert") !== -1) {
if (isHttps) {
certStatus.className = "check-status ok";
certStatus.textContent = "HTTPS connection verified";
certNext.disabled = false;
certNext.textContent = "Next";
} else {
checkHttps();
}
}
// PWA: mark setup as pending so the app redirects here on first standalone launch
if (steps.indexOf("pwa") !== -1) {
var stepsBeforePwa = steps.indexOf("pwa");
localStorage.setItem("setup-pending", String(stepsBeforePwa + 1));
}
// Android: PWA is optional, show skip button and update text
if (isAndroid && steps.indexOf("pwa") !== -1) {
var pwaSkip = document.getElementById("pwa-skip");
var pwaStatus = document.getElementById("pwa-status");
if (pwaSkip) pwaSkip.style.display = "block";
if (pwaStatus) pwaStatus.textContent = "Optional: install for quick access and full-screen experience.";
}
// Push: show warning if not on HTTPS
if (!isHttps && !isLocal) {
pushBtn.style.display = "none";
pushNeedsHttps.style.display = "flex";
pushNext.style.display = "block";
pushNext.textContent = "Finish anyway";
}
bar.innerHTML = "";
var stepCount = steps.length - 1;
var displayTotal = stepCount + stepOffset;
if (displayTotal <= 1) {
bar.style.display = "none";
var labels = document.querySelectorAll(".step-label");
for (var i = 0; i < labels.length; i++) labels[i].style.display = "none";
} else {
for (var i = 0; i < displayTotal; i++) {
var pip = document.createElement("div");
pip.className = "pip" + (i < stepOffset ? " done" : "");
bar.appendChild(pip);
}
for (var i = 0; i < totalEls.length; i++) totalEls[i].textContent = displayTotal;
}
}
function showStep(idx) {
currentStep = idx;
var cards = document.querySelectorAll(".step-card");
for (var i = 0; i < cards.length; i++) cards[i].classList.remove("active");
document.getElementById("step-" + steps[idx]).classList.add("active");
var pips = bar.querySelectorAll(".pip");
var displayIdx = idx + stepOffset;
for (var i = 0; i < pips.length; i++) {
pips[i].className = "pip" + (i < displayIdx ? " done" : i === displayIdx ? " active" : "");
}
for (var i = 0; i < curEls.length; i++) curEls[i].textContent = displayIdx + 1;
}
function nextStep() {
// After cert step on HTTP, redirect to HTTPS for remaining steps
if (!isHttps && steps[currentStep] === "cert") {
location.replace(httpsUrl + "/setup" + (lanMode ? "?mode=lan" : ""));
return;
}
if (currentStep < steps.length - 1) showStep(currentStep + 1);
}
// --- Step: Tailscale ---
var tsStatus = document.getElementById("ts-status");
var tsNext = document.getElementById("ts-next");
var tsUrlHint = document.getElementById("tailscale-url-hint");
if (isTailscale) {
tsStatus.className = "check-status ok";
tsStatus.textContent = "Connected via Tailscale (" + location.hostname + ")";
tsNext.disabled = false;
tsNext.textContent = "Next";
} else if (isLocal) {
tsStatus.className = "check-status ok";
tsStatus.textContent = "Running locally. Tailscale is optional.";
tsNext.disabled = false;
tsNext.textContent = "Next";
} else {
tsStatus.className = "check-status warn";
tsStatus.textContent = "You are not on a Tailscale network. Install Tailscale and access the relay via your 100.x.x.x IP.";
tsNext.disabled = false;
tsNext.textContent = "Next";
}
// Show the Tailscale URL hint
if (httpsUrl.indexOf("100.") !== -1) {
tsUrlHint.textContent = "Your relay: " + httpsUrl;
} else if (httpUrl.indexOf("100.") !== -1) {
tsUrlHint.textContent = "Your relay: " + httpUrl;
}
// --- Step: Certificate ---
// Same pattern as main page HTTP->HTTPS check: fetch httpsUrl/info (has CORS headers).
// If cert is trusted, fetch succeeds -> enable Next. Otherwise show retry.
var certStatus = document.getElementById("cert-status");
var certNext = document.getElementById("cert-next");
var certRetry = document.getElementById("cert-retry");
function checkHttps() {
certStatus.className = "check-status pending";
certStatus.textContent = "Checking HTTPS connection...";
certRetry.style.display = "none";
certNext.disabled = true;
certNext.textContent = "Verifying...";
var ac = new AbortController();
setTimeout(function() { ac.abort(); }, 3000);
fetch(httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
.then(function() {
// Any response (even opaque/401) means TLS handshake succeeded = cert is trusted
certStatus.className = "check-status ok";
certStatus.textContent = "HTTPS connection verified. Certificate is trusted.";
certNext.disabled = false;
certNext.textContent = "Next";
certRetry.style.display = "none";
})
.catch(function() {
certStatus.className = "check-status warn";
certStatus.textContent = "Certificate not trusted yet. Install it above, then retry.";
certRetry.style.display = "block";
certNext.disabled = true;
certNext.textContent = "Waiting for HTTPS...";
});
}
// cert check is now triggered inside buildSteps() after steps array is populated
// PWA setup-pending flag is now set inside buildSteps()
// --- Confetti ---
function fireConfetti() {
var canvas = document.createElement("canvas");
canvas.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999";
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
document.body.appendChild(canvas);
var ctx = canvas.getContext("2d");
var particles = [];
var colors = ["#DA7756","#57AB5A","#6CB6FF","#E8D44D","#DB61A2","#F0883E"];
for (var i = 0; i < 100; i++) {
var angle = Math.random() * Math.PI * 2;
var speed = Math.random() * 8 + 4;
particles.push({
x: canvas.width / 2,
y: canvas.height * 0.45,
vx: Math.cos(angle) * speed * (0.6 + Math.random()),
vy: Math.sin(angle) * speed * (0.6 + Math.random()) - 4,
color: colors[Math.floor(Math.random() * colors.length)],
w: Math.random() * 8 + 4,
h: Math.random() * 4 + 2,
rot: Math.random() * 360,
rotV: (Math.random() - 0.5) * 12,
alpha: 1
});
}
function tick() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
var alive = false;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (p.alpha <= 0) continue;
alive = true;
p.x += p.vx;
p.y += p.vy;
p.vy += 0.35;
p.vx *= 0.99;
p.rot += p.rotV;
p.alpha -= 0.008;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot * Math.PI / 180);
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.fillStyle = p.color;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
}
if (alive) requestAnimationFrame(tick);
else canvas.parentNode && canvas.parentNode.removeChild(canvas);
}
requestAnimationFrame(tick);
}
// --- Step: Push ---
var pushBtn = document.getElementById("push-enable-btn");
var pushStatus = document.getElementById("push-status");
var pushNeedsHttps = document.getElementById("push-needs-https");
var pushNext = document.getElementById("push-next");
function pushDone() {
pushBtn.style.display = "none";
pushStatus.style.display = "flex";
pushStatus.className = "check-status ok";
pushStatus.textContent = "Push notifications enabled!";
fireConfetti();
navigator.serviceWorker.ready.then(function(reg) {
reg.showNotification("\ud83c\udf89 Welcome to Clay!", {
body: "\ud83d\udd14 You\u2019ll be notified when Claude responds.",
tag: "claude-welcome",
});
}).catch(function() {});
setTimeout(function() { nextStep(); }, 1200);
}
// Push HTTPS check is now done inside buildSteps()
function enablePush() {
pushBtn.disabled = true;
pushBtn.textContent = "Requesting permission...";
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
pushStatus.style.display = "flex";
pushStatus.className = "check-status warn";
pushStatus.textContent = "Push notifications are not supported in this browser.";
pushBtn.style.display = "none";
pushNext.style.display = "block";
pushNext.textContent = "Finish anyway";
return;
}
navigator.serviceWorker.register("/sw.js")
.then(function() { return navigator.serviceWorker.ready; })
.then(function(reg) {
return fetch("/api/vapid-public-key", { cache: "no-store" })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.publicKey) throw new Error("No VAPID key");
var raw = atob(data.publicKey.replace(/-/g, "+").replace(/_/g, "/"));
var key = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) key[i] = raw.charCodeAt(i);
return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
});
})
.then(function(sub) {
var prevEndpoint = localStorage.getItem("push-endpoint");
localStorage.setItem("push-endpoint", sub.endpoint);
var payload = { subscription: sub.toJSON() };
if (prevEndpoint && prevEndpoint !== sub.endpoint) {
payload.replaceEndpoint = prevEndpoint;
}
return fetch("/api/push-subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
})
.then(pushDone)
.catch(function(err) {
pushBtn.disabled = false;
pushBtn.textContent = "Enable Push Notifications";
pushStatus.style.display = "flex";
pushNext.style.display = "block";
pushNext.textContent = "Finish anyway";
if (Notification.permission === "denied") {
pushStatus.className = "check-status warn";
pushStatus.textContent = "Notification permission was denied. Enable it in browser settings.";
} else {
pushStatus.className = "check-status warn";
pushStatus.textContent = "Could not enable push: " + (err.message || "unknown error");
}
});
}
// Done: clear setup-pending flag and link to app
var doneLink = document.getElementById("done-link");
doneLink.onclick = function() {
localStorage.removeItem("setup-pending");
localStorage.setItem("setup-done", "1");
};
if (isStandalone) {
doneLink.href = "/";
} else if (isHttps) {
doneLink.href = "/";
} else {
doneLink.href = httpsUrl;
}
// Init: try HTTPS redirect first (same as main page), then build steps
function init() {
detectPush().then(function(hasPushSub) {
buildSteps(hasPushSub);
showStep(0);
});
}
if (!isHttps && !isLocal) {
// Try redirecting to HTTPS like the main page does
fetch("/https-info").then(function(r) { return r.json(); }).then(function(info) {
if (!info.httpsUrl) { init(); return; }
var ac = new AbortController();
setTimeout(function() { ac.abort(); }, 3000);
fetch(info.httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
.then(function() { location.replace(info.httpsUrl + "/setup" + (lanMode ? "?mode=lan" : "")); })
.catch(function() { init(); });
}).catch(function() { init(); });
} else {
init();
}
</script>
</body></html>`;
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
// --- Build auth page CSS variables from the Clay Studio theme pair ---
var path = require("path");
var _authTheme = require(path.join(__dirname, "themes", "clay-light.json"));
var _authDarkTheme = require(path.join(__dirname, "themes", "clay-dark.json"));
function _hexToRgb(hex) {
hex = hex.replace("#", "");
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
};
}
function _hexToRgba(hex, alpha) {
var c = _hexToRgb(hex);
return "rgba(" + c.r + "," + c.g + "," + c.b + "," + alpha + ")";
}
function _mixColors(hex1, hex2, weight) {
var c1 = _hexToRgb(hex1), c2 = _hexToRgb(hex2);
var w = weight;
var r = Math.round(c1.r * w + c2.r * (1 - w));
var g = Math.round(c1.g * w + c2.g * (1 - w));
var b = Math.round(c1.b * w + c2.b * (1 - w));
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
var _keys = ["base00","base01","base02","base03","base04","base05","base06","base07",
"base08","base09","base0A","base0B","base0C","base0D","base0E","base0F"];
function _buildAuthVarDeclarations(theme) {
var t = {};
for (var ki = 0; ki < _keys.length; ki++) {
t[_keys[ki]] = "#" + theme[_keys[ki]];
}
var vars = {
"--bg": t.base00,
"--bg-alt": t.base01,
"--text": t.base06,
"--text-muted": t.base04,
"--text-dimmer": t.base03,
"--accent": t.base09,
"--accent-15": _hexToRgba(t.base09, 0.15),
"--accent-20": _hexToRgba(t.base09, 0.20),
"--border": t.base02,
"--input-bg": _mixColors(t.base01, t.base02, 0.5),
"--error": t.base08,
};
var declarations = "";
var names = Object.keys(vars);
for (var vi = 0; vi < names.length; vi++) {
declarations += names[vi] + ":" + vars[names[vi]] + ";";
}
return declarations;
}
var _authVarsStr = ":root{" + _buildAuthVarDeclarations(_authTheme) + "}" +
"@media(prefers-color-scheme:dark){:root{" + _buildAuthVarDeclarations(_authDarkTheme) + "}}";
// --- Shared CSS for auth pages ---
var authPageStyles =
_authVarsStr +
// Reset & layout
'*{margin:0;padding:0;box-sizing:border-box}' +
'body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;' +
'min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:20px}' +
'.c{max-width:380px;width:100%;text-align:center}' +
'h1{color:var(--text);font-size:24px;font-weight:700;margin-bottom:6px}' +
'.sub{color:var(--text-muted);font-size:14px;margin-bottom:28px}' +
'.field{margin-bottom:16px;text-align:left}' +
'.field label{display:block;font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px}' +
'input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:12px;' +
'color:var(--text);font-size:16px;padding:12px 14px;outline:none;font-family:inherit}' +
'input:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-15)}' +
'input::placeholder{font-size:14px;color:var(--text-dimmer)}' +
// PIN digit boxes
'.pin-wrap{display:flex;gap:8px;justify-content:center}' +
'.pin-digit{width:44px;height:56px;background:var(--input-bg);border:1.5px solid var(--border);border-radius:8px;' +
'color:var(--text);font-family:"Courier New",Courier,"Roboto Mono",monospace;font-size:28px;font-weight:700;' +
'text-align:center;line-height:56px;outline:none;caret-color:transparent;' +
'transition:border-color 0.15s,box-shadow 0.15s}' +
'.pin-digit:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-20)}' +
'.pin-digit.filled{color:var(--text)}' +
// Legacy single-input fallback
'.pin-input{font-size:24px;letter-spacing:12px;text-align:center;-webkit-text-security:disc;' +
'font-family:"Courier New",Courier,"Roboto Mono",monospace}' +
'.btn{width:100%;background:var(--accent);color:#fff;border:none;border-radius:12px;' +
'padding:14px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;' +
'transition:opacity 0.15s;margin-top:8px}' +
'.btn:hover{opacity:0.9}' +
'.btn:disabled{opacity:0.4;cursor:default}' +
'.err{color:var(--error);font-size:13px;margin-top:12px;min-height:1.3em}' +
'.info{color:var(--text-dimmer);font-size:12px;margin-top:16px}' +
// Step wizard
'.step{display:none}.step.active{display:block}' +
'.steps-bar{display:flex;gap:6px;justify-content:center;margin-bottom:28px}' +
'.steps-dot{width:8px;height:8px;border-radius:50%;background:var(--border);transition:background 0.2s}' +
'.steps-dot.done{background:var(--accent)}.steps-dot.current{background:var(--accent)}';
// --- Shared JS for PIN digit boxes ---
// initPinBoxes(containerId, hiddenInputId, onComplete) — wires up 6 individual digit inputs
var pinBoxScript =
'function initPinBoxes(cId,hId,onComplete){' +
'var wrap=document.getElementById(cId),hidden=document.getElementById(hId);' +
'var boxes=wrap.querySelectorAll(".pin-digit");' +
'var digits=["","","","","",""];' +
'boxes[0].focus();' +
'function setDigit(idx,v){digits[idx]=v;boxes[idx].value=v?"\\u2022":"";boxes[idx].classList.toggle("filled",v.length>0);syncHidden()}' +
'for(var i=0;i<boxes.length;i++){(function(idx){' +
'boxes[idx].addEventListener("input",function(e){' +
'var raw=this.value.replace(/[^0-9]/g,"");' +
'if(!raw){setDigit(idx,"");return}' +
'var v=raw.charAt(raw.length-1);' +
'setDigit(idx,v);' +
'if(v&&idx<5)boxes[idx+1].focus();' +
'if(hidden.value.length===6&&onComplete)onComplete()});' +
'boxes[idx].addEventListener("keydown",function(e){' +
'if(e.key==="Backspace"){if(!digits[idx]&&idx>0){setDigit(idx-1,"");boxes[idx-1].focus()}else{setDigit(idx,"")}syncHidden();return}' +
'if(e.key==="ArrowLeft"&&idx>0)boxes[idx-1].focus();' +
'if(e.key==="ArrowRight"&&idx<5)boxes[idx+1].focus();' +
'if(e.key==="Enter"&&hidden.value.length===6&&onComplete){e.preventDefault();onComplete()}});' +
'boxes[idx].addEventListener("paste",function(e){' +
'e.preventDefault();var d=(e.clipboardData||window.clipboardData).getData("text").replace(/[^0-9]/g,"").slice(0,6);' +
'for(var j=0;j<d.length&&j<6;j++){setDigit(j,d.charAt(j))}' +
'if(d.length>=6){boxes[5].focus();if(onComplete)onComplete()}else if(d.length>0)boxes[d.length].focus()});' +
'boxes[idx].addEventListener("focus",function(){this.select()});' +
'})(i)}' +
'function syncHidden(){var v="";for(var j=0;j<digits.length;j++)v+=digits[j];hidden.value=v}' +
'window.resetPinBoxes=function(){for(var j=0;j<6;j++){digits[j]="";boxes[j].value="";boxes[j].classList.remove("filled")}hidden.value="";boxes[0].focus()}' +
'}';
// HTML fragment for 6 PIN digit boxes + hidden input
var pinBoxesHtml =
'<div class="pin-wrap" id="pin-boxes">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">' +
'</div>' +
'<input type="hidden" id="pin">';
// --- Admin Setup Page (4-step wizard: setup code → email → display name → PIN) ---
function adminSetupPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Admin Setup - Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<div class="steps-bar"><span class="steps-dot current" id="dot0"></span><span class="steps-dot" id="dot1"></span><span class="steps-dot" id="dot2"></span><span class="steps-dot" id="dot3"></span></div>' +
// Step 1: Setup Code
'<div class="step active" id="step0">' +
'<h1>Set up your server</h1>' +
'<div class="sub">Enter the 6-character code shown in your terminal</div>' +
'<div class="field"><label>Setup Code</label>' +
'<input id="code" type="text" maxlength="6" placeholder="6-character code" autocomplete="off" autofocus></div>' +
'<button class="btn" id="btn0" disabled>Continue</button>' +
'<div class="err" id="err0"></div>' +
'</div>' +
// Step 2: Username
'<div class="step" id="step1">' +
'<h1>Pick a username</h1>' +
'<div class="sub">This is how others will identify you</div>' +
'<div class="field"><label>Username</label>' +
'<input id="username" type="text" maxlength="100" placeholder="Username" autocomplete="username"></div>' +
'<button class="btn" id="btn1" disabled>Continue</button>' +
'<div class="err" id="err1"></div>' +
'</div>' +
// Step 3: Display Name
'<div class="step" id="step2">' +
'<h1>What should we call you?</h1>' +
'<div class="sub">Your display name is shown in conversations</div>' +
'<div class="field"><label>Display Name</label>' +
'<input id="displayname" type="text" maxlength="30" placeholder="Your name" autocomplete="name"></div>' +
'<button class="btn" id="btn2" disabled>Continue</button>' +
'<div class="err" id="err2"></div>' +
'</div>' +
// Step 4: PIN
'<div class="step" id="step3">' +
'<h1>Secure your account</h1>' +
'<div class="sub">Set a 6-digit PIN for quick login</div>' +
pinBoxesHtml +
'<button class="btn" id="btn3" disabled style="margin-top:20px">Create Account</button>' +
'<div class="err" id="err3"></div>' +
'</div>' +
'<script>' +
pinBoxScript +
'var step=0;' +
'var codeEl=document.getElementById("code"),usernameEl=document.getElementById("username"),dnEl=document.getElementById("displayname"),pinEl=document.getElementById("pin");' +
'var steps=[document.getElementById("step0"),document.getElementById("step1"),document.getElementById("step2"),document.getElementById("step3")];' +
'var dots=[document.getElementById("dot0"),document.getElementById("dot1"),document.getElementById("dot2"),document.getElementById("dot3")];' +
'var btns=[document.getElementById("btn0"),document.getElementById("btn1"),document.getElementById("btn2"),document.getElementById("btn3")];' +
'var errs=[document.getElementById("err0"),document.getElementById("err1"),document.getElementById("err2"),document.getElementById("err3")];' +
'function goStep(n){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");dots[step].classList.add("done");' +
'step=n;steps[step].classList.add("active");dots[step].classList.add("current");' +
'errs[step].textContent="";' +
'if(step===1)usernameEl.focus();' +
'if(step===2){dnEl.focus();if(!dnEl.value)dnEl.value=usernameEl.value}' +
'if(step===3){initPinBoxes("pin-boxes","pin",function(){if(!btns[3].disabled)doSetup()});' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'for(var i=0;i<boxes.length;i++)boxes[i].addEventListener("input",function(){btns[3].disabled=pinEl.value.length!==6})}' +
'}' +
// Step 1 validation
'codeEl.addEventListener("input",function(){btns[0].disabled=codeEl.value.length<4});' +
'btns[0].onclick=function(){goStep(1)};' +
// Step 2 validation (username)
'usernameEl.addEventListener("input",function(){btns[1].disabled=usernameEl.value.trim().length<1});' +
'usernameEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[1].disabled)goStep(2)});' +
'btns[1].onclick=function(){goStep(2)};' +
// Step 3 validation (display name)
'dnEl.addEventListener("input",function(){btns[2].disabled=dnEl.value.trim().length<1});' +
'dnEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[2].disabled)goStep(3)});' +
'btns[2].onclick=function(){goStep(3)};' +
'function doSetup(){' +
'btns[3].disabled=true;errs[3].textContent="";' +
'fetch("/auth/setup",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({setupCode:codeEl.value,username:usernameEl.value.trim(),displayName:dnEl.value.trim(),pin:pinEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.href="/";return}' +
'errs[3].textContent=d.error||"Setup failed";btns[3].disabled=false})' +
'.catch(function(){errs[3].textContent="Connection error";btns[3].disabled=false})}' +
'btns[3].onclick=doSetup;' +
'</script></div></body></html>';
}
// --- Multi-user Login Page ---
function multiUserLoginPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Login - Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<div class="steps-bar"><span class="steps-dot current" id="dot0"></span><span class="steps-dot" id="dot1"></span></div>' +
// Step 1: Username
'<div class="step active" id="step0">' +
'<h1>Welcome back</h1>' +
'<div class="sub">Enter your username to log in</div>' +
'<div class="field"><label>Username</label>' +
'<input id="username" type="text" maxlength="100" placeholder="Username" autocomplete="username" autofocus></div>' +
'<button class="btn" id="btn0" disabled>Continue</button>' +
'<div class="err" id="err0"></div>' +
'</div>' +
// Step 2: PIN
'<div class="step" id="step1">' +
'<h1>Enter your PIN</h1>' +
'<div class="sub">6-digit PIN for your account</div>' +
pinBoxesHtml +
'<button class="btn" id="btn1" disabled style="margin-top:20px">Log In</button>' +
'<div class="err" id="err1"></div>' +
'</div>' +
'<script>' +
pinBoxScript +
'var step=0;' +
'var usernameEl=document.getElementById("username"),pinEl=document.getElementById("pin");' +
'var steps=[document.getElementById("step0"),document.getElementById("step1")];' +
'var dots=[document.getElementById("dot0"),document.getElementById("dot1")];' +
'var btns=[document.getElementById("btn0"),document.getElementById("btn1")];' +
'var errs=[document.getElementById("err0"),document.getElementById("err1")];' +
'function goStep(n){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");dots[step].classList.add("done");' +
'step=n;steps[step].classList.add("active");dots[step].classList.add("current");' +
'errs[step].textContent="";' +
'if(step===1){initPinBoxes("pin-boxes","pin",function(){if(!btns[1].disabled)doLogin()});' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'for(var i=0;i<boxes.length;i++)boxes[i].addEventListener("input",function(){btns[1].disabled=pinEl.value.length!==6})}' +
'}' +
// Step 1: username validation
'usernameEl.addEventListener("input",function(){btns[0].disabled=usernameEl.value.length<1});' +
'usernameEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[0].disabled)goStep(1)});' +
'btns[0].onclick=function(){goStep(1)};' +
'function resetPin(){' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'for(var i=0;i<boxes.length;i++)boxes[i].disabled=false;' +
'if(window.resetPinBoxes)resetPinBoxes();btns[1].disabled=true}' +
'function goBackToUsername(){' +
'steps[1].classList.remove("active");dots[1].classList.remove("current");dots[1].classList.remove("done");' +
'dots[0].classList.remove("done");dots[0].classList.add("current");' +
'steps[0].classList.add("active");step=0;' +
'errs[0].textContent="";errs[1].textContent="";usernameEl.focus()}' +
'function doLogin(){' +
'btns[1].disabled=true;errs[1].textContent="";' +
'fetch("/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({username:usernameEl.value,pin:pinEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
// mustChangePin is handled by the main app overlay
// (showForceChangePinOverlay in app-misc.js): it has a logout escape
// hatch and proper error display. Reload either way to enter it.
'if(d.ok){location.reload();return}' +
'if(d.locked){var boxes=document.querySelectorAll(".pin-digit");' +
'for(var i=0;i<boxes.length;i++)boxes[i].disabled=true;' +
'errs[1].textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";' +
'setTimeout(function(){resetPin()},d.retryAfter*1000);return}' +
'var msg=d.error||"Invalid credentials";' +
'if(typeof d.attemptsLeft==="number"&&d.attemptsLeft<=3)msg+=" ("+d.attemptsLeft+" left)";' +
'errs[1].textContent=msg;resetPin()})' +
'.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
'btns[1].onclick=doLogin;' +
'</script></div></body></html>';
}
// --- Invite Registration Page (3-step wizard: email → display name → PIN) ---
function invitePageHtml(inviteCode) {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Join - Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<div class="steps-bar"><span class="steps-dot current" id="dot0"></span><span class="steps-dot" id="dot1"></span><span class="steps-dot" id="dot2"></span></div>' +
// Step 1: Username
'<div class="step active" id="step0">' +
'<h1>You're invited!</h1>' +
'<div class="sub">Pick a username to get started</div>' +
'<div class="field"><label>Username</label>' +
'<input id="username" type="text" maxlength="100" placeholder="Username" autocomplete="username" autofocus></div>' +
'<button class="btn" id="btn0" disabled>Continue</button>' +
'<div class="err" id="err0"></div>' +
'</div>' +
// Step 2: Display Name
'<div class="step" id="step1">' +
'<h1>What should we call you?</h1>' +
'<div class="sub">Your display name is shown in conversations</div>' +
'<div class="field"><label>Display Name</label>' +
'<input id="displayname" type="text" maxlength="30" placeholder="Your name" autocomplete="name"></div>' +
'<button class="btn" id="btn1" disabled>Continue</button>' +
'<div class="err" id="err1"></div>' +
'</div>' +
// Step 3: PIN
'<div class="step" id="step2">' +
'<h1>Secure your account</h1>' +
'<div class="sub">Set a 6-digit PIN for quick login</div>' +
pinBoxesHtml +
'<button class="btn" id="btn2" disabled style="margin-top:20px">Create Account</button>' +
'<div class="err" id="err2"></div>' +
'</div>' +
'<script>' +
pinBoxScript +
'var inviteCode=' + JSON.stringify(inviteCode) + ';' +
'var step=0;' +
'var usernameEl=document.getElementById("username"),dnEl=document.getElementById("displayname"),pinEl=document.getElementById("pin");' +
'var steps=[document.getElementById("step0"),document.getElementById("step1"),document.getElementById("step2")];' +
'var dots=[document.getElementById("dot0"),document.getElementById("dot1"),document.getElementById("dot2")];' +
'var btns=[document.getElementById("btn0"),document.getElementById("btn1"),document.getElementById("btn2")];' +
'var errs=[document.getElementById("err0"),document.getElementById("err1"),document.getElementById("err2")];' +
'function goStep(n){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");dots[step].classList.add("done");' +
'step=n;steps[step].classList.add("active");dots[step].classList.add("current");' +
'errs[step].textContent="";' +
'if(step===1){dnEl.focus();if(!dnEl.value)dnEl.value=usernameEl.value}' +
'if(step===2){initPinBoxes("pin-boxes","pin",function(){if(!btns[2].disabled)doRegister()});' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'for(var i=0;i<boxes.length;i++)boxes[i].addEventListener("input",function(){btns[2].disabled=pinEl.value.length!==6})}' +
'}' +
// Step 1 validation (username)
'usernameEl.addEventListener("input",function(){btns[0].disabled=usernameEl.value.trim().length<1});' +
'usernameEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[0].disabled)goStep(1)});' +
'btns[0].onclick=function(){goStep(1)};' +
// Step 2 validation (display name)
'dnEl.addEventListener("input",function(){btns[1].disabled=dnEl.value.trim().length<1});' +
'dnEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[1].disabled)goStep(2)});' +
'btns[1].onclick=function(){goStep(2)};' +
'function doRegister(){' +
'btns[2].disabled=true;errs[2].textContent="";' +
'fetch("/auth/register",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({inviteCode:inviteCode,username:usernameEl.value.trim(),displayName:dnEl.value.trim(),pin:pinEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.href="/";return}' +
'errs[2].textContent=d.error||"Registration failed";btns[2].disabled=false})' +
'.catch(function(){errs[2].textContent="Connection error";btns[2].disabled=false})}' +
'btns[2].onclick=doRegister;' +
'</script></div></body></html>';
}
// --- SMTP OTP Login Page (2-step wizard: email → OTP code) ---
function smtpLoginPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Login - Clay Studio</title>' +
'<style>' + authPageStyles +
'.otp-input{width:100%;font-size:24px;letter-spacing:8px;text-align:center;padding:12px;' +
'background:var(--field-bg);border:1px solid var(--field-border);border-radius:8px;color:var(--fg);' +
'font-family:monospace;outline:none;box-sizing:border-box}' +
'.otp-input:focus{border-color:var(--accent)}' +
'.resend-row{text-align:center;margin-top:12px;font-size:13px;color:var(--muted)}' +
'.resend-link{color:var(--accent);cursor:pointer;text-decoration:underline;background:none;border:none;font:inherit}' +
'.resend-link:disabled{color:var(--muted);cursor:default;text-decoration:none}' +
'</style></head><body><div class="c">' +
'<div class="steps-bar"><span class="steps-dot current" id="dot0"></span><span class="steps-dot" id="dot1"></span></div>' +
// Step 1: Email
'<div class="step active" id="step0">' +
'<h1>Welcome back</h1>' +
'<div class="sub">Enter your email to receive a login code</div>' +
'<div class="field"><label>Email</label>' +
'<input id="email" type="email" maxlength="100" placeholder="you@example.com" autocomplete="email" autofocus></div>' +
'<button class="btn" id="btn0" disabled>Send Code</button>' +
'<div class="err" id="err0"></div>' +
'</div>' +
// Step 2: OTP Code
'<div class="step" id="step1">' +
'<h1>Check your inbox</h1>' +
'<div class="sub">Enter the 6-digit code sent to your email</div>' +
'<input class="otp-input" id="otp" type="tel" maxlength="6" inputmode="numeric" placeholder="000000" autocomplete="one-time-code">' +
'<button class="btn" id="btn1" disabled style="margin-top:20px">Log In</button>' +
'<div class="resend-row"><button class="resend-link" id="resend" disabled>Resend code (<span id="countdown">60</span>s)</button></div>' +
'<div class="err" id="err1"></div>' +
'</div>' +
// PIN fallback (hidden username+PIN form)
'<div id="pin-fallback" style="display:none">' +
'<h1>Log in with PIN</h1>' +
'<div class="sub">Use your username and PIN instead</div>' +
'<div class="field"><label>Username</label>' +
'<input id="fb-username" type="text" maxlength="100" placeholder="Username" autocomplete="username"></div>' +
'<div class="field" style="margin-top:12px"><label>PIN</label>' +
'<input id="fb-pin" type="password" maxlength="6" inputmode="numeric" placeholder="6-digit PIN" autocomplete="current-password"></div>' +
'<button class="btn" id="fb-btn" disabled style="margin-top:16px">Log In</button>' +
'<div class="err" id="fb-err"></div>' +
'<div class="resend-row"><button class="resend-link" id="fb-back">Back to email login</button></div>' +
'</div>' +
'<div class="resend-row" id="pin-link-row"><button class="resend-link" id="pin-link">Log in with PIN instead</button></div>' +
'<script>' +
'var step=0,cooldown=0,cooldownTimer=null;' +
'var emailEl=document.getElementById("email"),otpEl=document.getElementById("otp");' +
'var steps=[document.getElementById("step0"),document.getElementById("step1")];' +
'var dots=[document.getElementById("dot0"),document.getElementById("dot1")];' +
'var btns=[document.getElementById("btn0"),document.getElementById("btn1")];' +
'var errs=[document.getElementById("err0"),document.getElementById("err1")];' +
'var resendBtn=document.getElementById("resend"),cdSpan=document.getElementById("countdown");' +
'function goStep(n){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");dots[step].classList.add("done");' +
'step=n;steps[step].classList.add("active");dots[step].classList.add("current");' +
'errs[step].textContent="";' +
'if(step===1){otpEl.value="";otpEl.focus()}' +
'}' +
'function startCooldown(){' +
'cooldown=60;resendBtn.disabled=true;' +
'cdSpan.textContent=cooldown;' +
'if(cooldownTimer)clearInterval(cooldownTimer);' +
'cooldownTimer=setInterval(function(){cooldown--;cdSpan.textContent=cooldown;' +
'if(cooldown<=0){clearInterval(cooldownTimer);cooldownTimer=null;resendBtn.disabled=false;' +
'resendBtn.innerHTML="Resend code"}},1000)}' +
// Step 1: email validation + send OTP
'emailEl.addEventListener("input",function(){btns[0].disabled=emailEl.value.length<1});' +
'emailEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[0].disabled)requestCode()});' +
'function requestCode(){' +
'btns[0].disabled=true;errs[0].textContent="";' +
'fetch("/auth/request-otp",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({email:emailEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){goStep(1);startCooldown();return}' +
'if(d.locked){errs[0].textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";return}' +
'errs[0].textContent=d.error||"Failed to send code";btns[0].disabled=false})' +
'.catch(function(){errs[0].textContent="Connection error";btns[0].disabled=false})}' +
'btns[0].onclick=requestCode;' +
// Step 2: OTP validation
'otpEl.addEventListener("input",function(){' +
'var v=this.value.replace(/[^0-9]/g,"");if(v.length>6)v=v.slice(0,6);this.value=v;' +
'btns[1].disabled=v.length!==6;' +
'if(v.length===6)doLogin()});' +
'otpEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[1].disabled)doLogin()});' +
// Resend
'resendBtn.onclick=function(){' +
'resendBtn.disabled=true;' +
'fetch("/auth/request-otp",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({email:emailEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){if(d.ok){startCooldown();errs[1].textContent=""}' +
'else{errs[1].textContent=d.error||"Failed to resend";resendBtn.disabled=false}})' +
'.catch(function(){errs[1].textContent="Connection error";resendBtn.disabled=false})};' +
'function doLogin(){' +
'btns[1].disabled=true;errs[1].textContent="";' +
'fetch("/auth/verify-otp",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({email:emailEl.value,code:otpEl.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.reload();return}' +
'if(d.locked){otpEl.disabled=true;' +
'errs[1].textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";' +
'setTimeout(function(){otpEl.disabled=false;otpEl.value="";btns[1].disabled=true;otpEl.focus()},d.retryAfter*1000);return}' +
'var msg=d.error||"Invalid code";' +
'if(typeof d.attemptsLeft==="number")msg+=" ("+d.attemptsLeft+" left)";' +
'errs[1].textContent=msg;otpEl.value="";btns[1].disabled=true;otpEl.focus()})' +
'.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
'btns[1].onclick=doLogin;' +
// PIN fallback logic
'var pinFb=document.getElementById("pin-fallback"),pinLinkRow=document.getElementById("pin-link-row");' +
'var fbUser=document.getElementById("fb-username"),fbPin=document.getElementById("fb-pin");' +
'var fbBtn=document.getElementById("fb-btn"),fbErr=document.getElementById("fb-err");' +
'document.getElementById("pin-link").onclick=function(){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");' +
'pinFb.style.display="block";pinLinkRow.style.display="none";' +
'document.querySelector(".steps-bar").style.display="none";fbUser.focus()};' +
'document.getElementById("fb-back").onclick=function(){' +
'pinFb.style.display="none";pinLinkRow.style.display="";' +
'document.querySelector(".steps-bar").style.display="";' +
'step=0;steps[0].classList.add("active");dots[0].classList.add("current");emailEl.focus()};' +
'function checkFb(){fbBtn.disabled=!(fbUser.value.length>0&&fbPin.value.length===6)}' +
'fbUser.addEventListener("input",checkFb);fbPin.addEventListener("input",checkFb);' +
'function doPinLogin(){' +
'fbBtn.disabled=true;fbErr.textContent="";' +
'fetch("/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({username:fbUser.value,pin:fbPin.value})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.reload();return}' +
'var msg=d.error||"Invalid credentials";' +
'if(typeof d.attemptsLeft==="number"&&d.attemptsLeft<=3)msg+=" ("+d.attemptsLeft+" left)";' +
'fbErr.textContent=msg;fbPin.value="";fbBtn.disabled=true;fbPin.focus()})' +
'.catch(function(){fbErr.textContent="Connection error";fbBtn.disabled=false})}' +
'fbBtn.onclick=doPinLogin;' +
'fbPin.addEventListener("keydown",function(e){if(e.key==="Enter"&&!fbBtn.disabled)doPinLogin()});' +
'</script></div></body></html>';
}
// --- SMTP Invite Registration Page (2-step wizard: email → display name, no PIN) ---
function smtpInvitePageHtml(inviteCode) {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Join - Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<div class="steps-bar"><span class="steps-dot current" id="dot0"></span><span class="steps-dot" id="dot1"></span><span class="steps-dot" id="dot2"></span></div>' +
// Step 1: Username
'<div class="step active" id="step0">' +
'<h1>You're invited!</h1>' +
'<div class="sub">Pick a username to get started</div>' +
'<div class="field"><label>Username</label>' +
'<input id="username" type="text" maxlength="100" placeholder="Username" autocomplete="username" autofocus></div>' +
'<button class="btn" id="btn0" disabled>Continue</button>' +
'<div class="err" id="err0"></div>' +
'</div>' +
// Step 2: Email
'<div class="step" id="step1">' +
'<h1>Add your email</h1>' +
'<div class="sub">You'll use this to log in later</div>' +
'<div class="field"><label>Email</label>' +
'<input id="email" type="email" maxlength="100" placeholder="you@example.com" autocomplete="email"></div>' +
'<button class="btn" id="btn1" disabled>Continue</button>' +
'<div class="err" id="err1"></div>' +
'</div>' +
// Step 3: Display Name
'<div class="step" id="step2">' +
'<h1>What should we call you?</h1>' +
'<div class="sub">Your display name is shown in conversations</div>' +
'<div class="field"><label>Display Name</label>' +
'<input id="displayname" type="text" maxlength="30" placeholder="Your name" autocomplete="name"></div>' +
'<button class="btn" id="btn2" disabled>Create Account</button>' +
'<div class="err" id="err2"></div>' +
'</div>' +
'<script>' +
'var inviteCode=' + JSON.stringify(inviteCode) + ';' +
'var step=0;' +
'var usernameEl=document.getElementById("username"),emailEl=document.getElementById("email"),dnEl=document.getElementById("displayname");' +
'var steps=[document.getElementById("step0"),document.getElementById("step1"),document.getElementById("step2")];' +
'var dots=[document.getElementById("dot0"),document.getElementById("dot1"),document.getElementById("dot2")];' +
'var btns=[document.getElementById("btn0"),document.getElementById("btn1"),document.getElementById("btn2")];' +
'var errs=[document.getElementById("err0"),document.getElementById("err1"),document.getElementById("err2")];' +
'var emailRe=/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;' +
'function goStep(n){' +
'steps[step].classList.remove("active");dots[step].classList.remove("current");dots[step].classList.add("done");' +
'step=n;steps[step].classList.add("active");dots[step].classList.add("current");' +
'errs[step].textContent="";' +
'if(step===1)emailEl.focus();' +
'if(step===2){dnEl.focus();if(!dnEl.value)dnEl.value=usernameEl.value}' +
'}' +
// Step 1 validation (username)
'usernameEl.addEventListener("input",function(){btns[0].disabled=usernameEl.value.trim().length<1});' +
'usernameEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[0].disabled)goStep(1)});' +
'btns[0].onclick=function(){goStep(1)};' +
// Step 2 validation (email)
'emailEl.addEventListener("input",function(){' +
'var v=emailEl.value;var valid=emailRe.test(v);' +
'btns[1].disabled=!valid;' +
'if(v.length>0&&!valid)errs[1].textContent="Enter a valid email address";' +
'else errs[1].textContent=""});' +
'emailEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[1].disabled)goStep(2)});' +
'btns[1].onclick=function(){goStep(2)};' +
// Step 3 validation (display name)
'dnEl.addEventListener("input",function(){btns[2].disabled=dnEl.value.trim().length<1});' +
'dnEl.addEventListener("keydown",function(e){if(e.key==="Enter"&&!btns[2].disabled)doRegister()});' +
'function doRegister(){' +
'btns[2].disabled=true;errs[2].textContent="";' +
'fetch("/auth/register",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({inviteCode:inviteCode,username:usernameEl.value.trim(),email:emailEl.value,displayName:dnEl.value.trim()})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.href="/";return}' +
'errs[2].textContent=d.error||"Registration failed";btns[2].disabled=false})' +
'.catch(function(){errs[2].textContent="Connection error";btns[2].disabled=false})}' +
'btns[2].onclick=doRegister;' +
'</script></div></body></html>';
}
// --- No Projects Assigned Page (multi-user: user has no accessible projects) ---
function noProjectsPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<link rel="icon" type="image/png" sizes="32x32" href="/clay-studio-favicon-32.png">' +
'<title>Clay Studio</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<h1>Hang tight!</h1>' +
'<div class="sub">No projects have been assigned to your account yet.</div>' +
'<div class="info">Ask an admin to grant you access to a project.</div>' +
'</div></body></html>';
}
module.exports = { pinPageHtml: pinPageHtml, setupPageHtml: setupPageHtml, adminSetupPageHtml: adminSetupPageHtml, multiUserLoginPageHtml: multiUserLoginPageHtml, smtpLoginPageHtml: smtpLoginPageHtml, invitePageHtml: invitePageHtml, smtpInvitePageHtml: smtpInvitePageHtml, noProjectsPageHtml: noProjectsPageHtml };