feat: logout button, revoke/delete keys, setup script download

- Sidebar: Add Sign Out button below user info
- Keys API: split revoke (PATCH /{id}/revoke) and delete (DELETE /{id})
- Keys page: Revoke + Delete buttons per key; delete removes from DB
- New key flow: after creation show download setup script step
  - Script embeds API key, asks for projects root folder
  - Downloads cc-collector.py, merges Claude Code hook into settings.json
  - Tests connection and reports result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-26 14:05:14 +00:00
parent 354aec0995
commit 0c7a7b8082
3 changed files with 226 additions and 61 deletions

View file

@ -32,10 +32,19 @@ async def create_key(body: ApiKeyCreate, user: CurrentUser, db: AsyncSession = D
)
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{key_id}/revoke", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_key(key_id: str, user: CurrentUser, db: AsyncSession = Depends(get_db)):
key = await db.get(ApiKey, key_id)
if not key or key.user_id != user.id:
raise HTTPException(status_code=404, detail="Key not found")
key.is_active = False
await db.commit()
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_key(key_id: str, user: CurrentUser, db: AsyncSession = Depends(get_db)):
key = await db.get(ApiKey, key_id)
if not key or key.user_id != user.id:
raise HTTPException(status_code=404, detail="Key not found")
await db.delete(key)
await db.commit()

View file

@ -97,6 +97,10 @@ const App = (() => {
<strong>${_currentUser?.username || ''}</strong>
${_currentUser?.email || ''}
</div>
<button class="btn btn-ghost btn-sm" onclick="Api.logout()"
style="width:100%;justify-content:center;margin-top:8px;color:var(--text-muted)">
Sign Out
</button>
</div>
</div>
<div class="main">

View file

@ -18,102 +18,153 @@ const KeysPage = (() => {
<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>
<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>${k.is_active ? `<button class="btn btn-danger btn-sm" data-id="${k.id}">Revoke</button>` : ''}</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 class="chart-card" style="max-width:700px">
<h3>Hook Setup Instructions</h3>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
1. Create an API key above.<br>
2. Download <strong>cc-collector.py</strong> and save to <code style="color:var(--accent)">~/.claude/cc-collector.py</code><br>
3. Add the hook to your Claude Code settings:
</p>
<div style="position:relative">
<pre class="code-block" id="hook-snippet">${_buildHookSnippet('')}</pre>
<button class="copy-btn" onclick="copyHook()">Copy</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:12px">
Replace <code style="color:var(--accent)">YOUR_API_KEY</code> with your key after creating it.
</p>
<a href="/cc-dashboard/static/collector/cc-collector.py" download class="btn btn-ghost btn-sm" style="margin-top:12px"> Download cc-collector.py</a>
</div>
</div>
<!-- New key modal -->
<div class="modal-overlay" id="new-key-modal">
<div class="modal">
<h2>Create API Key</h2>
<div class="form-group">
<label>Label</label>
<input type="text" id="key-label" placeholder="My MacBook" value="My Machine">
</div>
<div id="key-result" style="display:none;margin-bottom:16px">
<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Your key (shown once copy it now):</p>
<div style="position:relative">
<pre class="code-block" id="key-raw" style="word-break:break-all"></pre>
<button class="copy-btn" onclick="copyRawKey()">Copy</button>
<!-- 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>
<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</button>
<!-- 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('new-key-modal').classList.add('open');
document.getElementById('key-result').style.display = 'none';
document.getElementById('step-create').style.display = '';
document.getElementById('step-done').style.display = 'none';
document.getElementById('key-error').style.display = 'none';
document.getElementById('btn-create-key').style.display = '';
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');
load(container);
};
// ── 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('key-raw').textContent = key.raw_key;
document.getElementById('key-result').style.display = 'block';
document.getElementById('btn-create-key').style.display = 'none';
// Update hook snippet
document.getElementById('hook-snippet').textContent = _buildHookSnippet(key.raw_key);
_setupDownloadStep(key.raw_key);
document.getElementById('step-create').style.display = 'none';
document.getElementById('step-done').style.display = '';
await load(container); // refresh table in background
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
};
// Revoke buttons
container.querySelectorAll('[data-id]').forEach(btn => {
document.getElementById('btn-done-key').onclick = () => {
document.getElementById('new-key-modal').classList.remove('open');
};
// ── Revoke buttons ──
container.querySelectorAll('.btn-revoke').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Revoke this key?')) return;
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) { alert(e.message); }
};
});
@ -122,29 +173,130 @@ const KeysPage = (() => {
}
}
function _buildHookSnippet(key) {
// Build setup script content with embedded API key
function _buildSetupScript(rawKey) {
const server = window.location.origin + '/cc-dashboard';
return `{
const collectorUrl = server + '/static/collector/cc-collector.py';
return `#!/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
read -rp " Enter your projects root folder (e.g. ~/Projects): " PROJECTS_ROOT
PROJECTS_ROOT="${PROJECTS_ROOT/#\\~/$HOME}"
if [ ! -d "$PROJECTS_ROOT" ]; then
echo " ⚠ Directory '$PROJECTS_ROOT' not found. Please create it first."
exit 1
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 " ✓ Saved to $COLLECTOR_PATH"
# Write Claude Code settings.json
HOOK_CMD="CC_API_KEY=$CC_API_KEY CC_SERVER=$CC_SERVER CC_ROOT_PATH=$PROJECTS_ROOT python3 $COLLECTOR_PATH 2>/dev/null || true"
if [ -f "$SETTINGS_PATH" ]; then
# Merge: back up existing settings and add hook
cp "$SETTINGS_PATH" "$SETTINGS_PATH.bak"
python3 - "$SETTINGS_PATH" "$HOOK_CMD" <<'PYEOF'
import json, sys
path, cmd = sys.argv[1], sys.argv[2]
with open(path) as f:
cfg = json.load(f)
hook_entry = {"hooks": [{"type": "command", "command": cmd, "async": True, "statusMessage": "Syncing to CC Dashboard…"}]}
cfg.setdefault("hooks", {}).setdefault("Stop", [])
# Remove any existing cc-collector entry
cfg["hooks"]["Stop"] = [h for h in cfg["hooks"]["Stop"] if "cc-collector" not in str(h)]
cfg["hooks"]["Stop"].append(hook_entry)
with open(path, "w") as f:
json.dump(cfg, f, indent=2)
PYEOF
echo " ✓ Merged hook into existing $SETTINGS_PATH (backup: .bak)"
else
cat > "$SETTINGS_PATH" <<JSONEOF
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "CC_API_KEY=${key || 'YOUR_API_KEY'} CC_SERVER=${server} python3 ~/.claude/cc-collector.py 2>/dev/null || true",
"command": "$HOOK_CMD",
"async": true,
"statusMessage": "Syncing to CC Dashboard…"
}]
}]
}
}`;
}
JSONEOF
echo " ✓ Created $SETTINGS_PATH"
fi
# Test connection
echo ""
echo " Testing connection to CC Dashboard…"
HTTP_CODE=$(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_CODE" = "204" ] || [ "$HTTP_CODE" = "200" ]; then
echo " ✓ Connection OK"
else
echo " ⚠ Could not reach $CC_SERVER (HTTP $HTTP_CODE)"
echo " The hook is installed — it will retry automatically on next Claude session."
fi
echo ""
echo " ────────────────────────────────────────────"
echo " Setup complete! Start a Claude Code session"
echo " to begin tracking your projects."
echo ""
`;
}
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 };
})();
function copyHook() {
navigator.clipboard.writeText(document.getElementById('hook-snippet').textContent);
}
function copyRawKey() {
navigator.clipboard.writeText(document.getElementById('key-raw').textContent);
}