feat(admin): implement provider-specific storage configuration pages
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled
Refactor the admin storage backend creation and editing flows to use provider-specific pages (e.g., `/admin/storage/new/sftp`) instead of a single generic form. This ensures only relevant fields are rendered for each storage provider (such as SFTP, S3, or WebDAV). Additionally: - Prevent mutation of the storage provider type during backend edits. - Add comprehensive unit tests for provider-specific rendering, edit validation, and CSRF/admin route protection.
This commit is contained in:
@@ -79,6 +79,7 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* View-mode summary */
|
||||
@@ -123,7 +124,6 @@
|
||||
/* Edit-mode body */
|
||||
.storage-card:not(.is-editing) .storage-card-body { display: none; }
|
||||
.storage-card.is-editing .storage-card-summary { display: none; }
|
||||
.storage-card.is-editing .storage-edit-trigger { display: none; }
|
||||
|
||||
.storage-card-body {
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -173,18 +173,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Add storage section */
|
||||
.storage-add-section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.storage-add-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.storage-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
@@ -203,6 +191,7 @@
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
@@ -229,16 +218,249 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.storage-new-card {
|
||||
border: 1px dashed rgba(125, 211, 252, 0.4);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15));
|
||||
.storage-ops-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.storage-new-card .storage-card-header {
|
||||
.storage-op-card {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
}
|
||||
|
||||
.storage-op-card strong {
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-op-card span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-op-card .button {
|
||||
justify-self: start;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.storage-form-note {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.storage-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.storage-modal-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(30rem, 100%);
|
||||
max-height: min(42rem, 90vh);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.storage-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-new-card .storage-card-body {
|
||||
border-top: none;
|
||||
.storage-speed-form,
|
||||
.storage-results-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-results-page {
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.storage-tests-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-speed-option {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 90%, var(--muted));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.storage-speed-option span {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.storage-speed-option small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.storage-custom-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-custom-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-custom-fields label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.storage-result-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||
}
|
||||
|
||||
.storage-result-row summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.storage-test-progress {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem 0.65rem;
|
||||
}
|
||||
|
||||
.storage-test-progress-bar {
|
||||
height: 0.45rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-test-progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: color-mix(in srgb, var(--primary) 70%, #86efac);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.storage-test-progress small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: end;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-status.is-done { color: #86efac; }
|
||||
.storage-result-status.is-failed { color: #fca5a5; }
|
||||
.storage-result-status.is-running { color: #fde68a; }
|
||||
|
||||
.storage-result-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.storage-result-detail span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.76rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-result-detail strong {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-error {
|
||||
grid-column: 1 / -1;
|
||||
color: #fca5a5 !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.storage-ops-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-row summary,
|
||||
.storage-result-detail,
|
||||
.storage-custom-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +1,115 @@
|
||||
(function () {
|
||||
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
||||
document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = document.querySelector("[data-storage-speed-modal]");
|
||||
if (modal) {
|
||||
modal.hidden = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function syncStorageProvider(select) {
|
||||
const formScope = select.closest("form");
|
||||
if (!formScope) {
|
||||
document.querySelectorAll("[data-storage-modal-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = button.closest(".storage-modal");
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
const provider = select.value;
|
||||
const isContabo = provider === "contabo";
|
||||
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
||||
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
||||
const active = providers.includes(provider);
|
||||
group.hidden = !active;
|
||||
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
||||
input.disabled = !active;
|
||||
});
|
||||
document.querySelectorAll(".storage-modal").forEach((modal) => {
|
||||
modal.hidden = true;
|
||||
});
|
||||
const tls = formScope.querySelector('input[name="use_ssl"]');
|
||||
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
||||
if (tls) {
|
||||
tls.checked = isContabo || tls.checked;
|
||||
tls.disabled = isContabo;
|
||||
}
|
||||
if (pathStyle) {
|
||||
pathStyle.checked = isContabo || pathStyle.checked;
|
||||
pathStyle.disabled = isContabo;
|
||||
}
|
||||
}
|
||||
|
||||
storageProviderSelects.forEach((select) => {
|
||||
select.addEventListener("change", () => syncStorageProvider(select));
|
||||
syncStorageProvider(select);
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-edit-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const card = button.closest(".storage-card");
|
||||
if (!card) {
|
||||
document.querySelectorAll(".storage-speed-form").forEach((form) => {
|
||||
const customFields = form.querySelector("[data-storage-custom-fields]");
|
||||
function syncCustomFields() {
|
||||
if (!customFields) {
|
||||
return;
|
||||
}
|
||||
card.classList.add("is-editing");
|
||||
const providerSelect = card.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-cancel-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const card = button.closest(".storage-card");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const form = card.querySelector("form");
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
card.classList.remove("is-editing");
|
||||
});
|
||||
});
|
||||
|
||||
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
||||
const storageTypePicker = document.querySelector(".storage-type-picker");
|
||||
const storageNewCard = document.querySelector(".storage-new-card");
|
||||
|
||||
const providerLabels = {
|
||||
s3: "S3 bucket",
|
||||
contabo: "Contabo Object Storage",
|
||||
sftp: "SFTP",
|
||||
smb: "Samba",
|
||||
webdav: "WebDAV",
|
||||
};
|
||||
|
||||
if (storageAddTrigger && storageTypePicker) {
|
||||
storageAddTrigger.addEventListener("click", () => {
|
||||
storageTypePicker.hidden = !storageTypePicker.hidden;
|
||||
if (storageNewCard && !storageTypePicker.hidden) {
|
||||
storageNewCard.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
storageTypePicker.querySelectorAll(".storage-type-option").forEach((option) => {
|
||||
option.addEventListener("click", () => {
|
||||
const provider = option.dataset.provider;
|
||||
if (!storageNewCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
|
||||
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
||||
if (typeBadge) {
|
||||
typeBadge.textContent = providerLabels[provider] || provider;
|
||||
}
|
||||
|
||||
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
||||
const optIcon = option.querySelector("svg");
|
||||
if (iconEl && optIcon) {
|
||||
iconEl.innerHTML = optIcon.outerHTML;
|
||||
}
|
||||
|
||||
storageTypePicker.hidden = true;
|
||||
storageNewCard.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (storageNewCard) {
|
||||
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
storageNewCard.hidden = true;
|
||||
if (storageTypePicker) {
|
||||
storageTypePicker.hidden = true;
|
||||
}
|
||||
const customSelected = form.querySelector('input[name="mode"]:checked')?.value === "custom";
|
||||
customFields.hidden = !customSelected;
|
||||
customFields.querySelectorAll("input").forEach((input) => {
|
||||
input.disabled = !customSelected;
|
||||
});
|
||||
}
|
||||
form.querySelectorAll('input[name="mode"]').forEach((input) => {
|
||||
input.addEventListener("change", syncCustomFields);
|
||||
});
|
||||
syncCustomFields();
|
||||
});
|
||||
|
||||
const testList = document.querySelector("[data-storage-tests-page]");
|
||||
if (!testList) {
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function renderTest(test) {
|
||||
const progress = Math.max(0, Math.min(100, Number(test.progress || 0)));
|
||||
const error = test.error
|
||||
? `<span class="storage-result-error"><strong>Error</strong>${escapeHTML(test.error)}</span>`
|
||||
: "";
|
||||
return `
|
||||
<details class="storage-result-row" data-storage-test-id="${escapeHTML(test.id)}">
|
||||
<summary>
|
||||
<span>${escapeHTML(test.startedLabel)}</span>
|
||||
<span>${escapeHTML(test.customLabel || test.modeLabel)}</span>
|
||||
<span class="storage-result-status is-${escapeHTML(test.status)}">${escapeHTML(test.status)}</span>
|
||||
</summary>
|
||||
<div class="storage-test-progress" aria-label="Test progress">
|
||||
<div class="storage-test-progress-bar"><span style="width: ${progress}%"></span></div>
|
||||
<small>${progress}%${test.stage ? " · " + escapeHTML(test.stage) : ""}</small>
|
||||
</div>
|
||||
<div class="storage-result-detail">
|
||||
<span><strong>Finished</strong>${escapeHTML(test.finishedLabel)}</span>
|
||||
<span><strong>Files</strong>${escapeHTML(test.files)}</span>
|
||||
<span><strong>Size</strong>${escapeHTML(test.sizeLabel)}</span>
|
||||
<span><strong>Write</strong>${escapeHTML(test.writeSpeed)}</span>
|
||||
<span><strong>Read</strong>${escapeHTML(test.readSpeed)}</span>
|
||||
${error}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function refreshTests() {
|
||||
const url = testList.getAttribute("data-storage-tests-url");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const openIDs = new Set(Array.from(testList.querySelectorAll("details[open]")).map((row) => row.dataset.storageTestId));
|
||||
const tests = payload.tests || [];
|
||||
if (tests.length === 0) {
|
||||
return;
|
||||
}
|
||||
testList.innerHTML = tests.map(renderTest).join("");
|
||||
testList.querySelectorAll("details").forEach((row) => {
|
||||
if (openIDs.has(row.dataset.storageTestId)) {
|
||||
row.open = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
refreshTests().catch(() => {});
|
||||
}, 1200);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user