Files
at-container-registry/pkg/appview/templates/pages/settings.html
T

502 lines
26 KiB
HTML

{{ define "settings" }}
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "head" . }}
{{ template "meta" .Meta }}
</head>
<body>
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">Settings</h1>
<!-- Mobile identity info (below lg) -->
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/50">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
</div>
<!-- Mobile tab bar (below lg) -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
{{ icon "hard-drive" "size-4" }} Storage
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices">
{{ icon "terminal" "size-4" }} Devices
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks">
{{ icon "webhook" "size-4" }} Webhooks
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced">
{{ icon "shield-check" "size-4" }} Advanced
</button>
</div>
<div class="flex gap-8">
<!-- Sidebar (lg and above) -->
<aside class="hidden lg:block w-56 shrink-0">
<ul class="menu bg-base-200 rounded-box w-full">
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
<li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li>
</ul>
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/50">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
</div>
</aside>
<!-- Tab content -->
<div class="flex-1 min-w-0">
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel hidden space-y-4">
<!-- Available Plans -->
{{ template "subscription_plans" .Subscription }}
<!-- Holds -->
{{ if .AllHolds }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-4">
{{ template "hold_selector" . }}
{{ if .ActiveHold }}
{{ template "hold_card" .ActiveHold }}
{{ else }}
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
No active hold selected. Choose one above.
</div>
{{ end }}
</div>
<div>
{{ if .OtherHolds }}
{{ template "other_holds_table" .OtherHolds }}
{{ end }}
</div>
</div>
{{ else }}
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
No holds configured. Push an image to get started.
</div>
{{ end }}
<!-- Storage Preferences -->
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Storage Preferences</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/auto-remove-untagged"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AutoRemoveUntagged }}checked{{ end }}>
<div>
<span class="font-medium">Automatically remove untagged manifests</span>
<p class="text-sm text-base-content/60 mt-1">
When a tag is overwritten, the old manifest and its layers are cleaned up.
Multi-arch child manifests are preserved.
</p>
</div>
</label>
</section>
</div>
<!-- DEVICES TAB -->
<div id="tab-devices" class="settings-panel hidden space-y-6">
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Authorized Devices</h2>
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
</div>
<!-- Setup Instructions -->
<div class="bg-base-200 rounded-lg p-4 space-y-4">
<h3 class="font-semibold">First Time Setup</h3>
<ol class="list-decimal list-inside space-y-4 text-sm">
<li>Install credential helper:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
</li>
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
"credHelpers": {
"{{ .RegistryURL }}": "atcr"
}
}</code></pre>
</li>
<li>Run any Docker command:
<div class="mt-2">{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
</li>
<li>Browser will open for authorization - click Approve</li>
<li>Done! Device is automatically authorized</li>
</ol>
<div class="pt-3 border-t border-base-300 text-sm">
<strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking)
</div>
</div>
<!-- Devices List -->
<div class="space-y-3">
<h3 class="font-semibold">Your Authorized Devices</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Device Name</th>
<th>IP Address</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="devices-table"
hx-get="/api/devices"
hx-trigger="tab:devices from:body once, every 30s[isTabActive('devices')], devicesChanged from:body"
hx-swap="innerHTML">
<tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<!-- WEBHOOKS TAB -->
<div id="tab-webhooks" class="settings-panel hidden space-y-6">
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<div>
<h2 class="text-xl font-semibold">Webhooks</h2>
<p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p>
</div>
<div id="webhooks-content">
{{ template "webhooks_list" .WebhooksData }}
</div>
</section>
</div>
<!-- ADVANCED TAB -->
<div id="tab-advanced" class="settings-panel hidden space-y-6">
<!-- Data Privacy Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Data Privacy</h2>
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
<div>
<a href="/api/export-data" class="btn btn-secondary gap-2" download>
{{ icon "download" "size-4" }}
Export All My Data
</a>
</div>
<p class="text-sm text-base-content/60">
This includes your authorized devices, sessions, and hold memberships.
Data stored on your PDS is already under your control.
See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details.
</p>
</section>
<!-- Danger Zone Section -->
<section class="border-2 border-error rounded-lg p-6 space-y-4">
<h2 class="text-xl font-semibold text-error flex items-center gap-2">
{{ icon "alert-triangle" "size-5" }}
Danger Zone
</h2>
<div class="space-y-4">
<div>
<h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3>
<p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p>
</div>
<div class="alert bg-base-200">
{{ icon "info" "size-5 shrink-0" }}
<span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span>
</div>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5">
<span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span>
</label>
<p class="text-xs text-base-content/60 ml-7">
This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS.
Other records in your account are not impacted.
</p>
</div>
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2">
{{ icon "trash-2" "size-5" }}
Delete My {{ .ClientShortName }} Data
</button>
</div>
</section>
</div>
</div>
</div>
</main>
<script>
// Tab switching
(function() {
var validTabs = ['storage', 'devices', 'webhooks', 'advanced'];
function switchSettingsTab(tabId) {
// Hide all panels
document.querySelectorAll('.settings-panel').forEach(function(p) {
p.classList.add('hidden');
});
// Show selected panel
var panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.remove('hidden');
// Sidebar: toggle menu-active on <li> elements
document.querySelectorAll('.menu li[data-tab]').forEach(function(li) {
if (li.dataset.tab === tabId) {
li.classList.add('menu-active');
} else {
li.classList.remove('menu-active');
}
});
// Mobile: toggle btn-secondary/btn-ghost on buttons
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
if (btn.dataset.tab === tabId) {
btn.classList.remove('btn-ghost');
btn.classList.add('btn-secondary');
} else {
btn.classList.remove('btn-secondary');
btn.classList.add('btn-ghost');
}
});
// Update URL hash without adding history entry
history.replaceState(null, '', '#' + tabId);
// Dispatch custom event for HTMX lazy loading
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
}
// Helper for HTMX conditional polling
window.isTabActive = function(tabId) {
var panel = document.getElementById('tab-' + tabId);
return panel && !panel.classList.contains('hidden');
};
document.addEventListener('DOMContentLoaded', function() {
// Read initial tab from hash
var hash = window.location.hash.replace('#', '') || 'storage';
if (validTabs.indexOf(hash) === -1) hash = 'storage';
// Mobile tab click handlers
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
switchSettingsTab(this.dataset.tab);
});
});
// Sidebar tab click handlers
document.querySelectorAll('.menu li[data-tab] a').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
switchSettingsTab(this.parentElement.dataset.tab);
});
});
// Activate initial tab
switchSettingsTab(hash);
});
// Handle browser back/forward
window.addEventListener('hashchange', function() {
var hash = window.location.hash.replace('#', '') || 'storage';
if (validTabs.indexOf(hash) !== -1) {
switchSettingsTab(hash);
}
});
})();
// Account Deletion JavaScript
(function() {
const deleteBtn = document.getElementById('delete-account-btn');
if (!deleteBtn) return;
deleteBtn.addEventListener('click', function() {
showDeleteConfirmationModal();
});
function showDeleteConfirmationModal() {
// Create modal using DaisyUI structure
const modal = document.createElement('div');
modal.className = 'modal modal-open';
modal.innerHTML = `
<div class="modal-box max-w-lg">
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#alert-triangle"></use></svg>
Delete {{ .ClientShortName }} Data
</h2>
<div class="py-4 space-y-4">
<div class="alert alert-success">
<svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
<span>Your ATProto account will <strong>NOT</strong> be affected.</span>
</div>
<p class="text-base-content/80">
This action <strong>cannot be undone</strong>. This will permanently delete:
</p>
<ul class="list-disc list-inside text-sm space-y-1 text-base-content/70">
<li>Your {{ .ClientShortName }} account and all settings</li>
<li>All authorized devices</li>
<li>Your data from all holds you're a member of</li>
${document.getElementById('delete-pds-records').checked ?
'<li>All io.atcr.* records from your ATProto PDS</li>' : ''}
</ul>
<div class="space-y-2">
<label for="confirm-delete-input" class="text-sm">Type <strong class="font-mono">DELETE {{ .Profile.Handle }}</strong> to confirm:</label>
<input type="text" id="confirm-delete-input" class="input input-bordered w-full font-mono" placeholder="DELETE {{ .Profile.Handle }}" autocomplete="off">
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="cancel-delete">Cancel</button>
<button type="button" class="btn btn-error gap-2" id="confirm-delete" disabled>
<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#trash-2"></use></svg>
Delete My {{ .ClientShortName }} Data
</button>
</div>
</div>
<div class="modal-backdrop bg-black/50" id="modal-backdrop"></div>
`;
document.body.appendChild(modal);
// Focus the input
const confirmInput = document.getElementById('confirm-delete-input');
const confirmBtn = document.getElementById('confirm-delete');
const cancelBtn = document.getElementById('cancel-delete');
setTimeout(() => confirmInput.focus(), 100);
// Expected confirmation string
const expectedConfirmation = 'DELETE {{ .Profile.Handle }}';
// Enable button only when full confirmation is typed
confirmInput.addEventListener('input', function() {
confirmBtn.disabled = this.value !== expectedConfirmation;
});
// Handle enter key
confirmInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value === expectedConfirmation) {
performAccountDeletion();
}
});
// Cancel button
cancelBtn.addEventListener('click', function() {
modal.remove();
});
// Click outside to close (on backdrop)
document.getElementById('modal-backdrop').addEventListener('click', function() {
modal.remove();
});
// Escape key to close
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', escHandler);
}
});
// Confirm delete
confirmBtn.addEventListener('click', performAccountDeletion);
async function performAccountDeletion() {
const deletePDS = document.getElementById('delete-pds-records').checked;
// Show loading state
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<svg class="icon size-4 animate-spin" aria-hidden="true"><use href="/icons.svg#loader-2"></use></svg> Deleting...';
cancelBtn.disabled = true;
try {
const response = await fetch('/api/account', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_pds_records: deletePDS,
confirmation: expectedConfirmation
})
});
const result = await response.json();
if (response.ok && result.success) {
// Show success and redirect
modal.querySelector('.modal-box').innerHTML = `
<h2 class="text-xl font-bold flex items-center gap-2 text-success">
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
Account Deleted
</h2>
<div class="py-4 space-y-2">
<p>Your account has been successfully deleted.</p>
<p class="text-base-content/70">Redirecting to home page...</p>
</div>
`;
setTimeout(() => {
window.location.href = '/?deleted=true';
}, 2000);
} else {
// Show error
const errors = result.errors || ['An unknown error occurred'];
modal.querySelector('.modal-box').innerHTML = `
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
Deletion Failed
</h2>
<div class="py-4 space-y-4">
<p>There were errors during account deletion:</p>
<ul class="list-disc list-inside text-sm space-y-1 text-error">
${errors.map(e => '<li>' + escapeHtml(e) + '</li>').join('')}
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Close</button>
</div>
`;
}
} catch (err) {
console.error('Delete account error:', err);
modal.querySelector('.modal-box').innerHTML = `
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
Error
</h2>
<div class="py-4">
<p>Failed to delete account: ${escapeHtml(err.message)}</p>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Close</button>
</div>
`;
}
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
})();
</script>
{{ template "footer" . }}
</body>
</html>
{{ end }}