dm / commit
feat: add encrypted share link for distributing API access
public/index.html | 335 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 302 insertions(+), 33 deletions(-) diff --git a/public/index.html b/public/index.html index e5af3f2..66fb9bf 100644 --- a/public/index.html +++ b/public/index.html @@ -723,6 +723,116 @@ body { } .attribution a:hover { color: var(--gold); } +/* ── SHARE MODAL ────────────────────────────────── */ + +#share-modal { + display: none; + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0,0,0,0.75); + align-items: center; + justify-content: center; +} +#share-modal.open { display: flex; } + +.share-inner { + background: linear-gradient(135deg, #14140a, #1e1c0a); + border: 1px solid var(--gold-dim); + border-radius: 10px; + padding: 28px 32px; + width: 480px; + max-width: 95vw; + box-shadow: 0 0 60px rgba(201,168,76,0.2), 0 20px 60px rgba(0,0,0,0.8); +} + +.share-title { + font-family: 'Cinzel', serif; + font-size: 1.1rem; + font-weight: 600; + color: var(--gold-light); + margin-bottom: 20px; + letter-spacing: 0.06em; +} + +.share-row { + display: flex; + gap: 10px; + align-items: flex-end; + margin-bottom: 14px; +} + +.share-url-box { + font-family: monospace; + font-size: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + padding: 10px 12px; + color: var(--text); + word-break: break-all; + margin-bottom: 6px; + user-select: all; + cursor: text; +} + +.share-note { + font-size: 0.78rem; + color: var(--text-dim); + font-style: italic; + margin-bottom: 14px; +} + +.share-btn { + font-family: 'Cinzel', serif; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.08em; + cursor: pointer; + border-radius: 4px; + padding: 9px 18px; + transition: all 0.15s; +} + +.share-btn-primary { + color: #0d0d1a; + background: var(--gold); + border: none; +} +.share-btn-primary:hover { background: var(--gold-light); } +.share-btn-secondary { + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); +} +.share-btn-secondary:hover { border-color: var(--gold-dim); color: var(--text); } + +.share-link-trigger { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + letter-spacing: 0.1em; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; + margin-top: 10px; +} +.share-link-trigger:hover { border-color: var(--gold-dim); color: var(--gold); } + +/* access code row */ +.access-code-label { + font-family: 'Cinzel', serif; + font-size: 0.7rem; + letter-spacing: 0.12em; + color: var(--gold); + text-transform: uppercase; + margin-bottom: 6px; +} + #levelup-toast button { font-family: 'Cinzel', serif; font-size: 0.85rem; @@ -744,20 +854,36 @@ body { <div class="creation-sub">Powered by Claude — your story begins here</div> <div class="creation-section"> - <div class="section-label">Anthropic API Key</div> - <div class="field-group"> - <label>API Key</label> - <input type="password" id="api-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false"> + <!-- Shown when no encrypted key in URL (developer / own key) --> + <div id="own-key-section"> + <div class="section-label">Anthropic API Key</div> + <div class="field-group"> + <label>API Key</label> + <input type="password" id="api-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false"> + </div> + <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;min-height:18px"> + <span id="key-saved-badge" style="font-size:0.75rem;color:var(--hp-full);display:none">✓ Saved on this device</span> + <button id="forget-key-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;display:none" type="button">Forget saved key</button> + </div> + <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic"> + Sent directly to Anthropic — never forwarded elsewhere. Saved to this device so you only enter it once. + Get one free at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a> + — pay-as-you-go, a typical session costs a few cents. + </p> + <button class="share-link-trigger" id="open-share-modal">🔗 Generate share link</button> </div> - <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;min-height:18px"> - <span id="key-saved-badge" style="font-size:0.75rem;color:var(--hp-full);display:none">✓ Saved on this device</span> - <button id="forget-key-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;display:none" type="button">Forget saved key</button> + + <!-- Shown when #k=... is present in the URL (shared link recipient) --> + <div id="access-code-section" style="display:none"> + <div class="section-label">Access Code</div> + <div class="field-group"> + <label class="access-code-label">Enter the code you received</label> + <input type="password" id="access-code-input" placeholder="your passphrase" autocomplete="off" spellcheck="false"> + </div> + <p style="font-size:0.78rem;color:var(--text-dim);margin-top:8px;font-style:italic"> + You received a special link to this app. Enter the access code to unlock it. + </p> </div> - <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic"> - Sent directly to Anthropic — never forwarded elsewhere. Saved to this device so you only enter it once. - Get one free at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a> - — pay-as-you-go, a typical session costs a few cents. - </p> </div> <div class="creation-section"> @@ -876,6 +1002,37 @@ body { </div> </div> +<!-- Share link modal --> +<div id="share-modal"> + <div class="share-inner"> + <div class="share-title">🔗 Generate Share Link</div> + + <div class="field-group" style="margin-bottom:14px"> + <label>Your API Key</label> + <input type="password" id="share-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false" style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none"> + </div> + + <div class="field-group" style="margin-bottom:16px"> + <label>Access Code <span style="color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">(passphrase your users will enter)</span></label> + <input type="text" id="share-pass-input" placeholder="e.g. goblin-treasure-42" autocomplete="off" spellcheck="false" style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none"> + </div> + + <div class="share-row"> + <button class="share-btn share-btn-primary" id="share-generate-btn">Generate</button> + <button class="share-btn share-btn-secondary" id="share-close-btn">Cancel</button> + </div> + + <div id="share-result" style="display:none;margin-top:16px"> + <div style="font-family:'Cinzel',serif;font-size:0.65rem;letter-spacing:0.12em;color:var(--gold);text-transform:uppercase;margin-bottom:6px">Share this URL</div> + <div class="share-url-box" id="share-url-display"></div> + <div class="share-row" style="margin-bottom:8px"> + <button class="share-btn share-btn-primary" id="share-copy-btn">Copy URL</button> + </div> + <p class="share-note">⚠️ Share the access code <em>separately</em> from this link — by text, verbally, etc. The link alone is not enough to access the app.</p> + </div> + </div> +</div> + <!-- Level up toast --> <div id="levelup-toast"> <div class="toast-icon">⭐</div> @@ -885,6 +1042,47 @@ body { </div> <script> +// ── CRYPTO ──────────────────────────────────────────── + +async function deriveKey(passphrase, salt, usage) { + const enc = new TextEncoder(); + const raw = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' }, + raw, + { name: 'AES-GCM', length: 256 }, + false, + [usage] + ); +} + +async function encryptApiKey(apiKey, passphrase) { + const enc = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(passphrase, salt, 'encrypt'); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(apiKey)); + const blob = new Uint8Array(16 + 12 + ct.byteLength); + blob.set(salt, 0); blob.set(iv, 16); blob.set(new Uint8Array(ct), 28); + return btoa(String.fromCharCode(...blob)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +async function decryptApiKey(b64url, passphrase) { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const blob = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + const salt = blob.slice(0, 16); + const iv = blob.slice(16, 28); + const ct = blob.slice(28); + const key = await deriveKey(passphrase, salt, 'decrypt'); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return new TextDecoder().decode(pt); +} + +function getSharedBlob() { + const params = new URLSearchParams(location.hash.slice(1)); + return params.get('k') || null; +} + // ── DATA ────────────────────────────────────────────── const RACES = [ @@ -992,36 +1190,107 @@ function buildCreationScreen() { classGrid.appendChild(card); }); - document.getElementById('begin-btn').addEventListener('click', beginAdventure); + document.getElementById('begin-btn').addEventListener('click', () => beginAdventure()); + + const sharedBlob = getSharedBlob(); + + if (sharedBlob) { + // Shared-link mode: show access code input, hide own-key section + document.getElementById('own-key-section').style.display = 'none'; + document.getElementById('access-code-section').style.display = ''; + } else { + // Own-key mode: restore from localStorage + const savedKey = localStorage.getItem('dm_api_key'); + if (savedKey) { + document.getElementById('api-key-input').value = savedKey; + document.getElementById('key-saved-badge').style.display = ''; + document.getElementById('forget-key-btn').style.display = ''; + } - // Restore saved API key - const savedKey = localStorage.getItem('dm_api_key'); - if (savedKey) { - document.getElementById('api-key-input').value = savedKey; - document.getElementById('key-saved-badge').style.display = ''; - document.getElementById('forget-key-btn').style.display = ''; - } + document.getElementById('forget-key-btn').addEventListener('click', () => { + localStorage.removeItem('dm_api_key'); + document.getElementById('api-key-input').value = ''; + document.getElementById('key-saved-badge').style.display = 'none'; + document.getElementById('forget-key-btn').style.display = 'none'; + }); - document.getElementById('forget-key-btn').addEventListener('click', () => { - localStorage.removeItem('dm_api_key'); - document.getElementById('api-key-input').value = ''; - document.getElementById('key-saved-badge').style.display = 'none'; - document.getElementById('forget-key-btn').style.display = 'none'; - }); + document.getElementById('api-key-input').addEventListener('input', () => { + document.getElementById('key-saved-badge').style.display = 'none'; + }); - document.getElementById('api-key-input').addEventListener('input', () => { - document.getElementById('key-saved-badge').style.display = 'none'; - }); + // Share modal + document.getElementById('open-share-modal').addEventListener('click', () => { + const cur = document.getElementById('api-key-input').value.trim(); + if (cur) document.getElementById('share-key-input').value = cur; + document.getElementById('share-result').style.display = 'none'; + document.getElementById('share-modal').classList.add('open'); + }); + + document.getElementById('share-close-btn').addEventListener('click', () => { + document.getElementById('share-modal').classList.remove('open'); + }); + + document.getElementById('share-modal').addEventListener('click', e => { + if (e.target === document.getElementById('share-modal')) + document.getElementById('share-modal').classList.remove('open'); + }); + + document.getElementById('share-generate-btn').addEventListener('click', async () => { + const apiKey = document.getElementById('share-key-input').value.trim(); + const passphrase = document.getElementById('share-pass-input').value.trim(); + if (!apiKey) { alert('Enter the API key to encrypt.'); return; } + if (!passphrase) { alert('Enter an access code / passphrase.'); return; } + const btn = document.getElementById('share-generate-btn'); + btn.disabled = true; btn.textContent = 'Encrypting…'; + try { + const blob = await encryptApiKey(apiKey, passphrase); + const url = `${location.origin}${location.pathname}#k=${blob}`; + document.getElementById('share-url-display').textContent = url; + document.getElementById('share-result').style.display = ''; + } catch (err) { + alert(`Encryption failed: ${err.message}`); + } finally { + btn.disabled = false; btn.textContent = 'Generate'; + } + }); + + document.getElementById('share-copy-btn').addEventListener('click', () => { + const url = document.getElementById('share-url-display').textContent; + navigator.clipboard.writeText(url).then(() => { + const btn = document.getElementById('share-copy-btn'); + btn.textContent = '✓ Copied!'; + setTimeout(() => btn.textContent = 'Copy URL', 2000); + }); + }); + } } function checkBeginReady() { document.getElementById('begin-btn').disabled = !(selectedRace && selectedClass); } -function beginAdventure() { - const apiKey = document.getElementById('api-key-input').value.trim(); - if (!apiKey) { alert('Please enter your Anthropic API key.'); return; } - localStorage.setItem('dm_api_key', apiKey); +async function beginAdventure() { + let apiKey; + const sharedBlob = getSharedBlob(); + + if (sharedBlob) { + const passphrase = document.getElementById('access-code-input').value.trim(); + if (!passphrase) { alert('Please enter your access code.'); return; } + const btn = document.getElementById('begin-btn'); + btn.disabled = true; btn.textContent = 'Unlocking…'; + try { + apiKey = await decryptApiKey(sharedBlob, passphrase); + } catch { + alert('Invalid access code. Please check and try again.'); + btn.disabled = false; btn.textContent = 'Begin Adventure'; + return; + } + btn.textContent = 'Begin Adventure'; + } else { + apiKey = document.getElementById('api-key-input').value.trim(); + if (!apiKey) { alert('Please enter your Anthropic API key.'); return; } + localStorage.setItem('dm_api_key', apiKey); + } const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`; const theme = document.getElementById('adventure-theme').value.trim();