352 lines
15 KiB
HTML
352 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>API Tokens — Dragonflight</title>
|
|
<link rel="stylesheet" href="css/common.css">
|
|
<style>
|
|
.token-card {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-4);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-4);
|
|
}
|
|
.token-card-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: var(--r-md);
|
|
background: var(--accent-subtle);
|
|
border: 1px solid var(--accent-border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
.token-card-body { flex: 1; min-width: 0; }
|
|
.token-card-name { font-weight: 500; font-size: var(--text-sm); }
|
|
.token-card-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
|
.token-prefix {
|
|
font-family: 'SF Mono', 'Consolas', monospace;
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
background: var(--bg-raised);
|
|
padding: 1px 6px;
|
|
border-radius: var(--r-sm);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.tokens-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
max-width: 680px;
|
|
}
|
|
.new-token-banner {
|
|
background: var(--status-green-bg);
|
|
border: 1px solid oklch(68% 0.18 148 / 0.30);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-4) var(--sp-5);
|
|
margin-bottom: var(--sp-5);
|
|
max-width: 680px;
|
|
}
|
|
.new-token-banner-title {
|
|
font-size: var(--text-sm);
|
|
font-weight: 500;
|
|
color: var(--status-green);
|
|
margin-bottom: var(--sp-2);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
}
|
|
.new-token-banner-warning {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
margin-top: var(--sp-3);
|
|
}
|
|
.copy-btn {
|
|
margin-top: var(--sp-3);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
|
|
<!-- Sidebar -->
|
|
<nav class="sidebar" aria-label="Main navigation">
|
|
<div class="sidebar-brand">
|
|
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" class="sidebar-logo">
|
|
<span class="sidebar-brand-name">Dragonflight</span>
|
|
</div>
|
|
<nav class="sidebar-nav">
|
|
<a href="home.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
|
|
Home
|
|
</a>
|
|
<a href="index.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
|
Library
|
|
</a>
|
|
<a href="projects.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
|
|
Projects
|
|
</a>
|
|
<a href="upload.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
|
Ingest
|
|
</a>
|
|
<a href="recorders.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
|
Recorders
|
|
</a>
|
|
<a href="capture.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
|
Capture
|
|
</a>
|
|
<a href="jobs.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
|
Jobs
|
|
</a>
|
|
<a href="edit.html" class="nav-item" target="_blank" rel="noopener">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
|
Editor
|
|
</a>
|
|
<div class="sidebar-section-label">Admin</div>
|
|
<a href="users.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
|
Users
|
|
</a>
|
|
<a href="tokens.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
|
Tokens
|
|
</a>
|
|
</nav>
|
|
<div class="sidebar-footer">
|
|
<div class="sidebar-user">
|
|
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
|
<div class="sidebar-user-info">
|
|
<div class="sidebar-user-name" id="userName">—</div>
|
|
<div class="sidebar-user-role" id="userRole"></div>
|
|
</div>
|
|
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main -->
|
|
<div class="main">
|
|
<header class="topbar">
|
|
<div class="topbar-left">
|
|
<span class="page-title">API Tokens</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
|
New token
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="page-content">
|
|
<div style="max-width:680px;margin-bottom:var(--sp-5);">
|
|
<p class="text-sm text-secondary" style="line-height:1.7;">
|
|
API tokens let scripts and integrations authenticate as you without using your password.
|
|
Tokens are shown once at creation — store them securely.
|
|
Use <code style="font-family:monospace;font-size:11px;background:var(--bg-surface);padding:1px 5px;border-radius:3px;border:1px solid var(--border)">Authorization: Bearer <token></code> in your requests.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- New token reveal (shown after creation) -->
|
|
<div id="newTokenBanner" style="display:none;" class="new-token-banner">
|
|
<div class="new-token-banner-title">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>
|
|
Token created
|
|
</div>
|
|
<div class="token-reveal" id="newTokenValue"></div>
|
|
<div class="new-token-banner-warning">
|
|
⚠ This is the only time this token will be shown. Copy it now.
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm copy-btn" onclick="copyToken()">Copy to clipboard</button>
|
|
</div>
|
|
|
|
<!-- Token list -->
|
|
<div class="tokens-list" id="tokensList">
|
|
<div style="color:var(--text-tertiary);font-size:var(--text-sm)">Loading…</div>
|
|
</div>
|
|
|
|
<div id="tokensEmpty" class="empty-state" style="display:none;padding:var(--sp-12) 0;">
|
|
<div class="empty-state-icon">
|
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="15" cy="25" r="8"/><path d="M21 19l10-10M28 10l3 3M30 8l2 2"/></svg>
|
|
</div>
|
|
<div class="empty-state-title">No tokens yet</div>
|
|
<div class="empty-state-body">Create a token to authenticate API requests without a password.</div>
|
|
<div class="empty-state-actions">
|
|
<button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">New token</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New token slide panel -->
|
|
<div class="slide-overlay" id="tokenOverlay" onclick="closeNewTokenPanel()"></div>
|
|
<div class="slide-panel" id="tokenPanel">
|
|
<div class="slide-panel-header">
|
|
<span class="slide-panel-title">New API token</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="closeNewTokenPanel()" style="padding:0;width:28px;height:28px;">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="slide-panel-body">
|
|
<div class="form-group">
|
|
<label class="form-label" for="tokenName">Token name</label>
|
|
<input type="text" id="tokenName" placeholder="e.g. Premiere Plugin, CI/CD Script">
|
|
<div class="form-hint">A label to help you remember what this token is for.</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="tokenExpiry">Expiry</label>
|
|
<select id="tokenExpiry">
|
|
<option value="">No expiry</option>
|
|
<option value="30">30 days</option>
|
|
<option value="90">90 days</option>
|
|
<option value="365">1 year</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="slide-panel-footer">
|
|
<button class="btn btn-ghost" onclick="closeNewTokenPanel()">Cancel</button>
|
|
<button class="btn btn-primary" id="createTokenBtn" onclick="createNewToken()">Create token</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
|
|
|
<script src="js/api.js?v=6"></script>
|
|
<script>
|
|
let latestToken = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', loadTokens);
|
|
|
|
async function loadTokens() {
|
|
const r = await getTokens();
|
|
const list = document.getElementById('tokensList');
|
|
const empty = document.getElementById('tokensEmpty');
|
|
|
|
if (!r.success) {
|
|
list.innerHTML = `<div class="text-sm text-tertiary">Could not load tokens.</div>`;
|
|
return;
|
|
}
|
|
|
|
const tokens = r.data;
|
|
if (!tokens.length) {
|
|
list.style.display = 'none';
|
|
empty.style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
list.style.display = 'flex';
|
|
empty.style.display = 'none';
|
|
|
|
list.innerHTML = tokens.map(t => {
|
|
const created = t.created_at ? new Date(t.created_at).toLocaleDateString() : '—';
|
|
const lastUsed = t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never';
|
|
const expires = t.expires_at ? new Date(t.expires_at).toLocaleDateString() : 'Never';
|
|
const isExpired = t.expires_at && new Date(t.expires_at) < new Date();
|
|
return `
|
|
<div class="token-card">
|
|
<div class="token-card-icon">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
|
</div>
|
|
<div class="token-card-body">
|
|
<div class="token-card-name">${esc(t.name)}</div>
|
|
<div class="token-card-meta">
|
|
<span class="token-prefix">${esc(t.token_prefix)}…</span>
|
|
· Created ${created}
|
|
· Last used: ${lastUsed}
|
|
· Expires: ${isExpired ? '<span style="color:var(--status-red)">Expired</span>' : expires}
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-danger btn-sm" onclick="confirmRevoke('${t.id}',${esc(JSON.stringify(t.name))})">Revoke</button>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function openNewTokenPanel() {
|
|
document.getElementById('tokenName').value = '';
|
|
document.getElementById('tokenExpiry').value = '';
|
|
document.getElementById('tokenPanel').classList.add('open');
|
|
document.getElementById('tokenOverlay').classList.add('open');
|
|
}
|
|
|
|
function closeNewTokenPanel() {
|
|
document.getElementById('tokenPanel').classList.remove('open');
|
|
document.getElementById('tokenOverlay').classList.remove('open');
|
|
}
|
|
|
|
async function createNewToken() {
|
|
const name = document.getElementById('tokenName').value.trim();
|
|
if (!name) { toast('Token name required', '', 'warning'); return; }
|
|
|
|
const expires_in_days = document.getElementById('tokenExpiry').value || null;
|
|
const btn = document.getElementById('createTokenBtn');
|
|
btn.disabled = true;
|
|
|
|
const r = await createToken({ name, expires_in_days: expires_in_days ? parseInt(expires_in_days) : null });
|
|
btn.disabled = false;
|
|
|
|
if (r.success) {
|
|
closeNewTokenPanel();
|
|
latestToken = r.data.token;
|
|
document.getElementById('newTokenValue').textContent = latestToken;
|
|
document.getElementById('newTokenBanner').style.display = 'block';
|
|
document.getElementById('newTokenBanner').scrollIntoView({ behavior: 'smooth' });
|
|
toast('Token created', name, 'success');
|
|
loadTokens();
|
|
} else {
|
|
toast('Failed to create token', r.error, 'error');
|
|
}
|
|
}
|
|
|
|
async function confirmRevoke(id, name) {
|
|
if (!confirm(`Revoke token "${name}"? Any scripts using it will stop working.`)) return;
|
|
const r = await revokeToken(id);
|
|
if (r.success) { toast('Token revoked', name, 'success'); loadTokens(); }
|
|
else toast('Failed to revoke token', r.error, 'error');
|
|
}
|
|
|
|
function copyToken() {
|
|
if (!latestToken) return;
|
|
navigator.clipboard.writeText(latestToken).then(() => {
|
|
toast('Copied to clipboard', '', 'success');
|
|
}).catch(() => {
|
|
toast('Copy failed — select and copy manually', '', 'warning');
|
|
});
|
|
}
|
|
|
|
function toast(title, msg, type = 'info') {
|
|
const icons = {
|
|
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
|
|
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
|
|
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
|
|
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
|
|
};
|
|
const el = document.createElement('div');
|
|
el.className = `toast toast--${type}`;
|
|
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg?`<div class="toast-msg">${esc(msg)}</div>`:''}</div>`;
|
|
document.getElementById('toastContainer').appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
<script src="js/auth-guard.js"></script>
|
|
</body>
|
|
</html>
|