mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-04 09:16:56 +00:00
360 lines
11 KiB
HTML
360 lines
11 KiB
HTML
{{ define "settings" }}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Settings - ATCR</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
</head>
|
|
<body>
|
|
{{ template "nav" . }}
|
|
|
|
<main class="container">
|
|
<div class="settings-page">
|
|
<h1>Settings</h1>
|
|
|
|
<!-- Identity Section -->
|
|
<section class="settings-section">
|
|
<h2>Identity</h2>
|
|
<div class="form-group">
|
|
<label>Handle:</label>
|
|
<span>{{ .Profile.Handle }}</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>DID:</label>
|
|
<code>{{ .Profile.DID }}</code>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>PDS:</label>
|
|
<span>{{ .Profile.PDSEndpoint }}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Default Hold Section -->
|
|
<section class="settings-section">
|
|
<h2>Default Hold</h2>
|
|
<p>Current: <strong>{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p>
|
|
|
|
<form hx-post="/api/profile/default-hold"
|
|
hx-target="#hold-status"
|
|
hx-swap="innerHTML">
|
|
|
|
<div class="form-group">
|
|
<label for="hold-endpoint">Hold Endpoint:</label>
|
|
<input type="text"
|
|
id="hold-endpoint"
|
|
name="hold_endpoint"
|
|
value="{{ .Profile.DefaultHold }}"
|
|
placeholder="https://hold.example.com" />
|
|
<small>Leave empty to use AppView default storage</small>
|
|
</div>
|
|
|
|
<button type="submit" class="btn-primary">Save</button>
|
|
</form>
|
|
|
|
<div id="hold-status"></div>
|
|
</section>
|
|
|
|
<!-- API Keys Section -->
|
|
<section class="settings-section api-keys-section">
|
|
<h2>API Keys</h2>
|
|
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
|
|
|
|
<!-- Generate New Key -->
|
|
<div class="generate-key">
|
|
<h3>Generate New API Key</h3>
|
|
<form id="generate-key-form">
|
|
<div class="form-group">
|
|
<label for="key-name">Key Name:</label>
|
|
<input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required>
|
|
</div>
|
|
<button type="submit" class="btn-primary">Generate Key</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Existing Keys List -->
|
|
<div class="keys-list">
|
|
<h3>Your API Keys</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Created</th>
|
|
<th>Last Used</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="keys-table">
|
|
<tr><td colspan="4">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- OAuth Session Section -->
|
|
<section class="settings-section">
|
|
<h2>OAuth Session</h2>
|
|
<div class="form-group">
|
|
<label>Logged in as:</label>
|
|
<span>{{ .Profile.Handle }}</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Session expires:</label>
|
|
<time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}">
|
|
{{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }}
|
|
</time>
|
|
</div>
|
|
<a href="/auth/oauth/login?return_to=/settings" class="btn-secondary">Re-authenticate</a>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Modal container for HTMX -->
|
|
<div id="modal"></div>
|
|
|
|
<!-- API Key Modal (shown once after generation) -->
|
|
<div id="key-modal" class="modal hidden">
|
|
<div class="modal-backdrop" onclick="closeKeyModal()"></div>
|
|
<div class="modal-content">
|
|
<h3>✓ API Key Generated!</h3>
|
|
<p><strong>Copy this key now - it won't be shown again:</strong></p>
|
|
<div class="key-display">
|
|
<code id="generated-key"></code>
|
|
<button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button>
|
|
</div>
|
|
<div class="usage-instructions">
|
|
<h4>Using with Docker:</h4>
|
|
<p><strong>Direct login (quick start)</strong></p>
|
|
<pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre>
|
|
<p><strong>Credential helper (if you opened this from configure)</strong></p>
|
|
<p>Just paste your handle and this key when prompted in the terminal.</p>
|
|
</div>
|
|
<button class="btn-primary" onclick="closeKeyModal()">Done</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/app.js"></script>
|
|
|
|
<script>
|
|
// API Key Management JavaScript
|
|
(function() {
|
|
// Generate key
|
|
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = document.getElementById('key-name').value;
|
|
|
|
try {
|
|
const resp = await fetch('/api/keys', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
body: `name=${encodeURIComponent(name)}`
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to generate key');
|
|
}
|
|
|
|
const data = await resp.json();
|
|
|
|
// Show key in modal (only time it's available)
|
|
document.getElementById('generated-key').textContent = data.key;
|
|
document.getElementById('key-modal').classList.remove('hidden');
|
|
|
|
// Clear form
|
|
document.getElementById('key-name').value = '';
|
|
|
|
// Refresh keys list
|
|
loadKeys();
|
|
} catch (err) {
|
|
alert('Error generating key: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Copy key to clipboard
|
|
window.copyKey = function() {
|
|
const key = document.getElementById('generated-key').textContent;
|
|
navigator.clipboard.writeText(key).then(() => {
|
|
alert('Copied to clipboard!');
|
|
}).catch(err => {
|
|
alert('Failed to copy: ' + err.message);
|
|
});
|
|
};
|
|
|
|
// Close modal
|
|
window.closeKeyModal = function() {
|
|
document.getElementById('key-modal').classList.add('hidden');
|
|
};
|
|
|
|
// Load existing keys
|
|
async function loadKeys() {
|
|
try {
|
|
const resp = await fetch('/api/keys');
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to load keys');
|
|
}
|
|
|
|
const keys = await resp.json();
|
|
const tbody = document.getElementById('keys-table');
|
|
|
|
if (keys.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = keys.map(key => {
|
|
const createdDate = new Date(key.created_at).toLocaleDateString();
|
|
const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z'
|
|
? new Date(key.last_used).toLocaleDateString()
|
|
: 'Never';
|
|
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(key.name)}</td>
|
|
<td>${createdDate}</td>
|
|
<td>${lastUsed}</td>
|
|
<td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('Error loading keys:', err);
|
|
document.getElementById('keys-table').innerHTML =
|
|
'<tr><td colspan="4">Error loading keys</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Delete key
|
|
window.deleteKey = async function(id) {
|
|
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to delete key');
|
|
}
|
|
loadKeys();
|
|
} catch (err) {
|
|
alert('Error revoking key: ' + err.message);
|
|
}
|
|
};
|
|
|
|
// Escape HTML helper
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Load keys on page load
|
|
loadKeys();
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
/* API Key Modal Styles */
|
|
.modal.hidden { display: none; }
|
|
.modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
.modal-backdrop {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
}
|
|
.modal-content {
|
|
position: relative;
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 8px;
|
|
max-width: 600px;
|
|
width: 90%;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
z-index: 1001;
|
|
}
|
|
.key-display {
|
|
background: #f5f5f5;
|
|
padding: 1rem;
|
|
margin: 1rem 0;
|
|
border-radius: 4px;
|
|
border: 1px solid #ddd;
|
|
}
|
|
.key-display code {
|
|
word-break: break-all;
|
|
font-size: 14px;
|
|
display: block;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.usage-instructions {
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: #e3f2fd;
|
|
border-radius: 4px;
|
|
}
|
|
.usage-instructions h4 {
|
|
margin-top: 0;
|
|
}
|
|
.usage-instructions pre {
|
|
background: #263238;
|
|
color: #aed581;
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
margin: 0.5rem 0 0 0;
|
|
}
|
|
.usage-instructions code {
|
|
font-family: monospace;
|
|
}
|
|
|
|
/* API Keys Section Styles */
|
|
.api-keys-section table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 1rem;
|
|
}
|
|
.api-keys-section th,
|
|
.api-keys-section td {
|
|
padding: 0.75rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
.api-keys-section th {
|
|
background: #f5f5f5;
|
|
font-weight: bold;
|
|
}
|
|
.api-keys-section .btn-danger {
|
|
background: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.api-keys-section .btn-danger:hover {
|
|
background: #c82333;
|
|
}
|
|
.generate-key {
|
|
margin: 1rem 0;
|
|
padding: 1rem;
|
|
background: #f8f9fa;
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|
|
{{ end }}
|