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:
parent
354aec0995
commit
0c7a7b8082
3 changed files with 226 additions and 61 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue