<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Canvas MCP</title>
<meta name="description"
content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<link rel="canonical" href="https://canvas.dunkirk.sh/dashboard" id="canonical-url">
<meta name="theme-color" content="#0066cc">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://canvas.dunkirk.sh/dashboard" id="og-url">
<meta property="og:title" content="Dashboard - Canvas MCP">
<meta property="og:site_name" content="Canvas MCP Server">
<meta property="og:description"
content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
<meta property="og:image" content="https://canvas.dunkirk.sh/og.png" id="og-image">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Canvas MCP Server - Connect Canvas LMS to AI assistants">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://canvas.dunkirk.sh/dashboard" id="twitter-url">
<meta property="twitter:title" content="Dashboard - Canvas MCP">
<meta property="twitter:description"
content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
<meta property="twitter:image" content="https://canvas.dunkirk.sh/og.png" id="twitter-image">
<script>
// Set dynamic URLs based on current host
const baseUrl = window.location.origin;
document.getElementById('canonical-url').setAttribute('href', `${baseUrl}/dashboard`);
document.getElementById('og-url').setAttribute('content', `${baseUrl}/dashboard`);
document.getElementById('og-image').setAttribute('content', `${baseUrl}/og.png`);
document.getElementById('twitter-url').setAttribute('content', `${baseUrl}/dashboard`);
document.getElementById('twitter-image').setAttribute('content', `${baseUrl}/og.png`);
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
max-width: 900px;
margin: 2rem auto;
padding: 2rem;
color: #111;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ddd;
}
h1 {
font-size: 1.75rem;
font-weight: 600;
}
button {
padding: 0.5rem 1rem;
background: #333;
color: white;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
}
button:hover {
background: #111;
}
.logout-btn {
background: #c33;
}
.logout-btn:hover {
background: #a22;
}
section {
margin: 2rem 0;
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
font-weight: 600;
}
.info-grid {
display: grid;
gap: 0.75rem;
}
.info-row {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-weight: 500;
color: #555;
}
.value {
color: #111;
font-family: monospace;
font-size: 0.95rem;
}
.api-key-section {
background: #f9f9f9;
padding: 1rem;
border-radius: 4px;
}
.api-key-display {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.api-key-value {
flex: 1;
padding: 0.75rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
overflow-x: auto;
white-space: nowrap;
}
.api-key-value:hover {
background: #f8f9fa;
}
.api-key-value.hidden {
filter: blur(6px);
user-select: none;
}
#mcpUrlValue {
user-select: all;
}
.btn-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.config-block {
background: #2d2d2d;
color: #f0f0f0;
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
overflow-x: auto;
margin-top: 1rem;
white-space: pre;
}
.hidden {
display: none;
}
.notification {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #2d2d2d;
color: white;
padding: 1rem 1.5rem;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.notification.show {
opacity: 1;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat {
text-align: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #0066cc;
}
.stat-label {
color: #666;
font-size: 0.9rem;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<header>
<h1>Dashboard</h1>
<button class="logout-btn" id="logoutBtn">Logout</button>
</header>
<section id="connectCanvasSection" style="display: none;">
<h2>Connect Canvas Account</h2>
<p style="color: #666; margin-bottom: 1.5rem;">
Connect your Canvas account to start using the MCP server with Claude.
</p>
<form id="connectCanvasForm">
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Canvas Domain</label>
<input type="text" id="canvasDomain" placeholder="canvas.university.edu"
style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;"
required />
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Personal Access Token</label>
<input type="password" id="accessToken" placeholder="Get this from Canvas → Settings → New Access Token"
style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;"
required />
</div>
<button type="submit" style="width: 100%; padding: 0.75rem;">Connect Canvas</button>
<div id="connectError"
style="display: none; margin-top: 1rem; padding: 0.75rem; background: #fee; border: 1px solid #fcc; border-radius: 4px; color: #c33;">
</div>
</form>
<details style="margin-top: 1.5rem;">
<summary style="cursor: pointer; color: #666;">How to get a Personal Access Token</summary>
<ol style="margin-top: 1rem; padding-left: 1.5rem; color: #666; font-size: 0.9rem;">
<li>Log in to your Canvas account</li>
<li>Go to Account → Settings</li>
<li>Scroll to "Approved Integrations"</li>
<li>Click "+ New Access Token"</li>
<li>Set Purpose: "MCP Server"</li>
<li>Click "Generate Token"</li>
<li>Copy the token and paste it above</li>
</ol>
</details>
</section>
<section id="accountSection" style="display: none;">
<h2>Account Information</h2>
<div class="info-grid" id="accountInfo">
<div class="info-row">
<span class="label">Loading...</span>
</div>
</div>
</section>
<section id="mcpConnectionSection" style="display: none;">
<h2>MCP Server Connection</h2>
<p style="color: #666; margin-bottom: 1.5rem;">
Use these credentials to connect your MCP client (Claude Desktop, Poke, etc.) to your Canvas account.
</p>
<!-- MCP Endpoint URL -->
<div style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<label style="font-weight: 600; color: #111;">MCP Server URL</label>
<button id="copyUrlBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Copy URL</button>
</div>
<div class="api-key-value" id="mcpUrlValue">
<span id="mcpUrl"></span>
</div>
<p style="font-size: 0.85rem; color: #666; margin-top: 0.5rem;">
This is your MCP server endpoint URL.
</p>
</div>
<!-- API Token -->
<div class="api-key-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<label style="font-weight: 600; color: #111;">API Token</label>
<button id="regenerateBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Regenerate Token</button>
</div>
<div id="apiKeyDisplay" style="display: none;">
<div
style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
<strong>⚠️ Save this token now!</strong> You won't be able to see it again after leaving this page.
</div>
<div class="api-key-display">
<div class="api-key-value" id="apiKeyValue"></div>
<button id="copyKeyBtn">Copy Token</button>
</div>
</div>
<div id="apiKeyHidden" style="display: none;">
<div class="api-key-display">
<div class="api-key-value" style="background: #f5f5f5; color: #999; user-select: none;">
••••••••••••••••••••••••••••••••••••••••
</div>
</div>
<p style="color: #666; margin-top: 0.5rem; font-size: 0.85rem;">
Your token is hidden for security. Click "Regenerate Token" above to create a new one.
</p>
</div>
</div>
</section>
<section id="quickSetupSection" style="display: none;">
<h2>Quick Setup</h2>
<p style="color: #666; margin-bottom: 1rem;">
Add the MCP url to the claude connections page as a custom connection and then authorize it. Poke and some other
clients require both the api key and the mcp url.
</p>
</section>
<section id="usageStatsSection" style="display: none;">
<h2>Usage Statistics</h2>
<div class="stat-grid" id="usageStats">
<div class="stat">
<div class="stat-value">-</div>
<div class="stat-label">Loading...</div>
</div>
</div>
</section>
<div class="notification" id="notification"></div>
<footer style="margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #ddd; font-size: 0.9rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #666;">made by <a href="https://dunkirk.sh" style="color: #666; text-decoration: none;">kieran
klukas</a></span>
<a id="git-hash-link" href="#"
style="color: #999; text-decoration: none; font-family: monospace; font-size: 0.85rem;">...</a>
</div>
</footer>
<script type="module">
// Load git hash
fetch('/api/version')
.then(r => r.json())
.then(data => {
const link = document.getElementById('git-hash-link');
if (link) {
link.href = `https://tangled.org/dunkirk.sh/canvas-mcp/commit/${data.hash}`;
link.textContent = data.shortHash;
}
})
.catch(() => { });
let userData = null;
let apiKeyVisible = false;
async function loadDashboard() {
try {
const response = await fetch('/api/user/me', {
credentials: 'include'
});
if (!response.ok) {
window.location.href = '/';
return;
}
userData = await response.json();
// Check if Canvas is connected
if (!userData.canvas_domain) {
// Show Canvas connection form, hide everything else
document.getElementById('connectCanvasSection').style.display = 'block';
document.getElementById('accountSection').style.display = 'none';
document.getElementById('mcpConnectionSection').style.display = 'none';
document.getElementById('quickSetupSection').style.display = 'none';
document.getElementById('usageStatsSection').style.display = 'none';
} else {
// Show everything when Canvas is connected
document.getElementById('connectCanvasSection').style.display = 'none';
document.getElementById('accountSection').style.display = 'block';
document.getElementById('mcpConnectionSection').style.display = 'block';
document.getElementById('quickSetupSection').style.display = 'block';
document.getElementById('usageStatsSection').style.display = 'block';
renderAccountInfo();
renderUsageStats();
setupApiKeyDisplay();
}
} catch (error) {
console.error('Failed to load dashboard:', error);
window.location.href = '/';
}
}
function renderAccountInfo() {
const container = document.getElementById('accountInfo');
container.innerHTML = `
<div class="info-row">
<span class="label">Canvas Domain</span>
<span class="value">${userData.canvas_domain}</span>
</div>
<div class="info-row">
<span class="label">Email</span>
<span class="value">${userData.email || 'Not provided'}</span>
</div>
<div class="info-row">
<span class="label">Created</span>
<span class="value">${new Date(userData.created_at).toLocaleDateString()}</span>
</div>
<div class="info-row">
<span class="label">Last Used</span>
<span class="value">${userData.last_used_at ? new Date(userData.last_used_at).toLocaleDateString() : 'Never'}</span>
</div>
`;
}
function renderUsageStats() {
const container = document.getElementById('usageStats');
const stats = userData.usage_stats || {};
container.innerHTML = `
<div class="stat">
<div class="stat-value">${stats.total_requests || 0}</div>
<div class="stat-label">Total Requests</div>
</div>
<div class="stat">
<div class="stat-value">${stats.requests_24h || 0}</div>
<div class="stat-label">Last 24 Hours</div>
</div>
<div class="stat">
<div class="stat-value">${stats.requests_7d || 0}</div>
<div class="stat-label">Last 7 Days</div>
</div>
`;
}
function showNotification(message) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.classList.add('show');
setTimeout(() => {
notification.classList.remove('show');
}, 2000);
}
function setupApiKeyDisplay() {
// Always show the MCP URL
const mcpUrl = `${window.location.origin}/mcp`;
document.getElementById('mcpUrl').textContent = mcpUrl;
if (userData.api_key) {
// Show the key (first time only)
document.getElementById('apiKeyDisplay').style.display = 'block';
document.getElementById('apiKeyValue').textContent = userData.api_key;
apiKeyVisible = true;
} else {
// Hide the key
document.getElementById('apiKeyHidden').style.display = 'block';
}
}
document.getElementById('copyUrlBtn').addEventListener('click', async () => {
const mcpUrl = `${window.location.origin}/mcp`;
await navigator.clipboard.writeText(mcpUrl);
showNotification('MCP URL copied to clipboard');
});
document.getElementById('copyKeyBtn').addEventListener('click', async () => {
await navigator.clipboard.writeText(userData.api_key);
showNotification('API token copied to clipboard');
});
let regenerateState = 'initial'; // 'initial', 'confirm', 'show-token'
let newToken = null;
document.getElementById('regenerateBtn').addEventListener('click', async () => {
const btn = document.getElementById('regenerateBtn');
if (regenerateState === 'initial') {
// First click: show confirmation
regenerateState = 'confirm';
btn.textContent = 'Click again to confirm';
btn.style.background = '#c33';
// Reset after 3 seconds if not confirmed
setTimeout(() => {
if (regenerateState === 'confirm') {
regenerateState = 'initial';
btn.textContent = 'Regenerate Token';
btn.style.background = '';
}
}, 3000);
} else if (regenerateState === 'confirm') {
// Second click: regenerate
btn.disabled = true;
btn.textContent = 'Regenerating...';
try {
const response = await fetch('/api/user/regenerate-key', {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
userData.api_key = data.api_key;
newToken = data.api_key;
// Show the new key
document.getElementById('apiKeyHidden').style.display = 'none';
document.getElementById('apiKeyDisplay').style.display = 'block';
document.getElementById('apiKeyValue').textContent = userData.api_key;
apiKeyVisible = true;
regenerateState = 'show-token';
btn.disabled = false;
btn.textContent = 'Click to copy new token';
btn.style.background = '#0066cc';
} catch (error) {
regenerateState = 'initial';
btn.disabled = false;
btn.textContent = 'Regenerate Token';
btn.style.background = '';
showNotification('Failed to regenerate token');
}
} else if (regenerateState === 'show-token') {
// Third click: copy token
await navigator.clipboard.writeText(newToken);
showNotification('New token copied to clipboard');
// Reset button
regenerateState = 'initial';
btn.textContent = 'Regenerate Token';
btn.style.background = '';
}
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
window.location.href = '/';
});
document.getElementById('connectCanvasForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const domain = document.getElementById('canvasDomain').value.trim();
const token = document.getElementById('accessToken').value.trim();
const errorDiv = document.getElementById('connectError');
const submitBtn = e.target.querySelector('button[type="submit"]');
errorDiv.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting...';
try {
const response = await fetch('/api/auth/token-login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({
canvas_domain: domain,
access_token: token
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to connect Canvas');
}
// Reload dashboard to show connected state
window.location.reload();
} catch (error) {
errorDiv.textContent = error.message;
errorDiv.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Connect Canvas';
}
});
loadDashboard();
</script>
</body>
</html>