cc-dashboard/src/static/js/pages/keys.js

289 lines
12 KiB
JavaScript

const KeysPage = (() => {
let _keys = [];
async function render(container) {
container.innerHTML = `<div class="page"><div class="loading">Loading…</div></div>`;
await load(container);
}
async function load(container) {
try {
_keys = await Api.get('/api/keys');
container.innerHTML = `
<div class="page">
<div class="section-header">
<h2>API Keys</h2>
<button class="btn btn-primary btn-sm" id="btn-new-key">+ New Key</button>
</div>
<div class="table-card" style="margin-bottom:24px">
<table>
<thead>
<tr>
<th>Label</th>
<th>Prefix</th>
<th>Last Used</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${_keys.length ? _keys.map(k => `
<tr>
<td><strong>${k.label}</strong></td>
<td><code style="color:var(--accent);font-size:12px">${k.key_prefix}…</code></td>
<td style="color:var(--text-muted);font-size:12px">${k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never'}</td>
<td>${k.is_active
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-muted">Revoked</span>'
}</td>
<td style="display:flex;gap:6px;justify-content:flex-end">
${k.is_active
? `<button class="btn btn-ghost btn-sm btn-revoke" data-id="${k.id}">Revoke</button>`
: ''
}
<button class="btn btn-danger btn-sm btn-delete" data-id="${k.id}">Delete</button>
</td>
</tr>
`).join('') : '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px">No API keys yet</td></tr>'}
</tbody>
</table>
</div>
</div>
<!-- New key modal -->
<div class="modal-overlay" id="new-key-modal">
<div class="modal">
<h2>Create API Key</h2>
<!-- Step 1: label input -->
<div id="step-create">
<div class="form-group">
<label>Label</label>
<input type="text" id="key-label" placeholder="Mac mini, Work Laptop…" value="My Machine">
</div>
<div id="key-error" class="error-msg"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-ghost" id="btn-cancel-key">Cancel</button>
<button class="btn btn-primary" id="btn-create-key">Create Key</button>
</div>
</div>
<!-- Step 2: download setup script -->
<div id="step-done" style="display:none">
<div style="
background:rgba(34,197,94,.08);
border:1px solid rgba(34,197,94,.25);
border-radius:var(--radius-sm);
padding:14px 16px;
margin-bottom:20px;
font-size:13px;
">
<strong style="color:var(--success)">Key created successfully.</strong><br>
<span style="color:var(--text-muted);font-size:12px">
Download the setup script below — it contains your API key and will configure everything automatically.
</span>
</div>
<div style="margin-bottom:20px">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-muted);margin-bottom:10px">
What the script does:
</div>
<ol style="font-size:12px;color:var(--text-muted);line-height:1.8;padding-left:18px">
<li>Asks you for your <strong style="color:var(--text)">projects root folder</strong></li>
<li>Downloads <code style="color:var(--accent)">cc-collector.py</code> to <code style="color:var(--accent)">~/.claude/</code></li>
<li>Adds the Claude Code Stop hook to your settings</li>
<li>Tests the connection to confirm everything works</li>
</ol>
</div>
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px 14px;font-size:11px;color:var(--text-muted);margin-bottom:20px;font-family:'Courier New',monospace;">
curl -s <span id="script-url-preview" style="color:var(--accent)"></span> | bash
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-ghost" id="btn-done-key">Close</button>
<button class="btn btn-primary" id="btn-download-script">
⬇ Download Setup Script
</button>
</div>
</div>
</div>
</div>
`;
// ── New key button ──
document.getElementById('btn-new-key').onclick = () => {
document.getElementById('step-create').style.display = '';
document.getElementById('step-done').style.display = 'none';
document.getElementById('key-error').style.display = 'none';
document.getElementById('key-label').value = 'My Machine';
document.getElementById('new-key-modal').classList.add('open');
};
document.getElementById('btn-cancel-key').onclick = () => {
document.getElementById('new-key-modal').classList.remove('open');
};
// ── Create key ──
document.getElementById('btn-create-key').onclick = async () => {
const label = document.getElementById('key-label').value.trim() || 'My Machine';
const errEl = document.getElementById('key-error');
errEl.style.display = 'none';
try {
const key = await Api.post('/api/keys', { label });
document.getElementById('step-create').style.display = 'none';
document.getElementById('step-done').style.display = '';
_setupDownloadStep(key.raw_key);
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
};
document.getElementById('btn-done-key').onclick = async () => {
document.getElementById('new-key-modal').classList.remove('open');
await load(container); // refresh table after modal closes
};
// ── Revoke buttons ──
container.querySelectorAll('.btn-revoke').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Revoke this key? It will stop working immediately.')) return;
try {
await Api.patch(`/api/keys/${btn.dataset.id}/revoke`);
await load(container);
} catch (e) { alert(e.message); }
};
});
// ── Delete buttons ──
container.querySelectorAll('.btn-delete').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Permanently delete this key? This cannot be undone.')) return;
try {
await Api.del(`/api/keys/${btn.dataset.id}`);
await load(container);
} catch (e) { alert(e.message); }
};
});
} catch (e) {
container.innerHTML = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
}
}
// Build setup script content with embedded API key
function _buildSetupScript(rawKey) {
const server = window.location.origin + '/cc-dashboard';
const collectorUrl = server + '/static/collector/cc-collector.py';
// Use array join to avoid JS template literal conflicts with bash ${VAR} syntax
const L = [
'#!/usr/bin/env bash',
'# ============================================================',
'# CC Dashboard — Setup Script',
'# Auto-generated — contains your API key — run once',
'# ============================================================',
'set -euo pipefail',
'',
'CC_API_KEY="' + rawKey + '"',
'CC_SERVER="' + server + '"',
'COLLECTOR_URL="' + collectorUrl + '"',
'CLAUDE_DIR="$HOME/.claude"',
'COLLECTOR_PATH="$CLAUDE_DIR/cc-collector.py"',
'SETTINGS_PATH="$CLAUDE_DIR/settings.json"',
'',
'echo ""',
'echo " CC Dashboard Setup"',
'echo " ─────────────────────────────────────────────"',
'echo ""',
'',
'# Ask for projects root folder',
'read -rp " Enter your projects root folder (e.g. ~/Projects): " PROJECTS_ROOT',
'# Expand ~ manually',
'PROJECTS_ROOT="${PROJECTS_ROOT/#\\~/$HOME}"',
'',
'if [ ! -d "$PROJECTS_ROOT" ]; then',
' echo " Warning: directory not found, creating it..."',
' mkdir -p "$PROJECTS_ROOT"',
'fi',
'',
'echo ""',
'echo " Using: $PROJECTS_ROOT"',
'echo ""',
'',
'# Download cc-collector.py',
'echo " Downloading cc-collector.py..."',
'mkdir -p "$CLAUDE_DIR"',
'curl -fsSL "$COLLECTOR_URL" -o "$COLLECTOR_PATH"',
'chmod +x "$COLLECTOR_PATH"',
'echo " OK: saved to $COLLECTOR_PATH"',
'',
'# Build hook command',
'HOOK_CMD="CC_API_KEY=$CC_API_KEY CC_SERVER=$CC_SERVER CC_ROOT_PATH=$PROJECTS_ROOT python3 $COLLECTOR_PATH 2>/dev/null || true"',
'',
'# Update Claude Code settings.json',
'if [ -f "$SETTINGS_PATH" ]; then',
' cp "$SETTINGS_PATH" "$SETTINGS_PATH.bak"',
' python3 -c "',
'import json, sys',
'path, cmd = sys.argv[1], sys.argv[2]',
'with open(path) as f: cfg = json.load(f)',
'entry = {\"hooks\": [{\"type\": \"command\", \"command\": cmd, \"async\": True, \"statusMessage\": \"Syncing to CC Dashboard...\"}]}',
'cfg.setdefault(\"hooks\", {}).setdefault(\"Stop\", [])',
'cfg[\"hooks\"][\"Stop\"] = [h for h in cfg[\"hooks\"][\"Stop\"] if \"cc-collector\" not in str(h)]',
'cfg[\"hooks\"][\"Stop\"].append(entry)',
'open(path, \"w\").write(json.dumps(cfg, indent=2))',
'" -- "$SETTINGS_PATH" "$HOOK_CMD"',
' echo " OK: merged hook into $SETTINGS_PATH (backup saved)"',
'else',
' python3 -c "',
'import json, sys',
'cmd = sys.argv[1]',
'cfg = {\"hooks\": {\"Stop\": [{\"hooks\": [{\"type\": \"command\", \"command\": cmd, \"async\": True, \"statusMessage\": \"Syncing to CC Dashboard...\"}]}]}}',
'open(sys.argv[2], \"w\").write(json.dumps(cfg, indent=2))',
'" -- "$HOOK_CMD" "$SETTINGS_PATH"',
' echo " OK: created $SETTINGS_PATH"',
'fi',
'',
'# Test connection',
'echo ""',
'echo " Testing connection..."',
'HTTP=$(curl -s -o /dev/null -w "%{http_code}" \\',
' -H "X-API-Key: $CC_API_KEY" \\',
' "$CC_SERVER/api/ingest" \\',
' -X POST -H "Content-Type: application/json" -d "[]" 2>/dev/null || echo 000)',
'if [ "$HTTP" = "204" ] || [ "$HTTP" = "200" ]; then',
' echo " OK: connection successful"',
'else',
' echo " Note: could not reach server (HTTP $HTTP) — hook will retry on next session"',
'fi',
'',
'echo ""',
'echo " ─────────────────────────────────────────────"',
'echo " Setup complete! Open Claude Code to start tracking."',
'echo ""',
];
return L.join('\n');
}
function _setupDownloadStep(rawKey) {
const server = window.location.origin + '/cc-dashboard';
const previewEl = document.getElementById('script-url-preview');
if (previewEl) previewEl.textContent = server + '/setup';
document.getElementById('btn-download-script').onclick = () => {
const script = _buildSetupScript(rawKey);
const blob = new Blob([script], { type: 'text/x-shellscript' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'cc-dashboard-setup.sh';
a.click();
URL.revokeObjectURL(a.href);
};
}
return { render };
})();