433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
// views/configure.js — dynamic remote form builder.
|
|
//
|
|
// Two modes:
|
|
// #/configure/new → provider picker (searchable grid)
|
|
// #/configure/new/<provider> → form for that backend
|
|
// #/configure/edit/<remote> → form pre-filled from /config/get
|
|
//
|
|
// Pulls backend metadata from /config/providers (cached in app state).
|
|
// OAuth backends (option named "token" with IsPassword) get a banner
|
|
// and disabled submit — user must run `rclone config` in a terminal.
|
|
|
|
import { post } from "../rc.js?v=security-fixes-1";
|
|
import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
|
|
import { t } from "../i18n.js?v=security-fixes-1";
|
|
|
|
// --- Route entrypoints ---
|
|
|
|
export async function renderConfigureNew({ provider = "" }) {
|
|
if (!provider) {
|
|
return renderProviderPicker();
|
|
}
|
|
const providers = await ensureProviders();
|
|
const info = providers.find((p) => p.Name === provider);
|
|
if (!info) {
|
|
document.getElementById("app").innerHTML = `
|
|
<div class="empty">
|
|
<h3>${t("error.unknown_remote")}</h3>
|
|
<p>${t("error.no_such_provider", escapeHtml(provider))}</p>
|
|
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
return renderForm({ provider: info, mode: "create" });
|
|
}
|
|
|
|
export async function renderConfigureEdit({ remote }) {
|
|
if (!remote) {
|
|
location.hash = "#/remotes";
|
|
return;
|
|
}
|
|
const app = document.getElementById("app");
|
|
app.innerHTML = `<div class="empty"><p>${t("loading.title")}…</p></div>`;
|
|
|
|
let typeName;
|
|
try {
|
|
const dump = await post("config/dump");
|
|
typeName = dump && dump[remote] && dump[remote].type;
|
|
} catch (e) {
|
|
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
|
|
return;
|
|
}
|
|
if (!typeName) {
|
|
app.innerHTML = `
|
|
<div class="empty">
|
|
<h3>${t("error.unknown_remote")}</h3>
|
|
<p>${t("error.no_such_remote", escapeHtml(remote))}</p>
|
|
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const providers = await ensureProviders();
|
|
const info = providers.find((p) => p.Name === typeName);
|
|
if (!info) {
|
|
toast(`${t("error.no_such_provider", typeName)}`, "error");
|
|
return;
|
|
}
|
|
|
|
let currentValues = {};
|
|
try {
|
|
currentValues = await post("config/get", { name: remote });
|
|
} catch (e) {
|
|
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
|
|
}
|
|
|
|
return renderForm({
|
|
provider: info,
|
|
mode: "edit",
|
|
remoteName: remote,
|
|
currentValues,
|
|
});
|
|
}
|
|
|
|
// --- Provider picker ---
|
|
|
|
async function renderProviderPicker() {
|
|
const app = document.getElementById("app");
|
|
app.innerHTML = `
|
|
<div class="section-head">
|
|
<div>
|
|
<h2>${t("configure.new_title")}</h2>
|
|
<p class="subtitle">${t("configure.new_subtitle")}</p>
|
|
</div>
|
|
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
|
|
</div>
|
|
<div class="provider-search">
|
|
<input id="provider-filter" class="input" type="search" placeholder="${t("configure.filter_placeholder")}" autocomplete="off">
|
|
</div>
|
|
<div id="provider-grid" class="connector-grid">
|
|
<p class="empty" style="grid-column:1/-1">${t("loading.title")}…</p>
|
|
</div>
|
|
`;
|
|
|
|
const grid = document.getElementById("provider-grid");
|
|
const filter = document.getElementById("provider-filter");
|
|
|
|
let providers;
|
|
try {
|
|
providers = await ensureProviders();
|
|
} catch (e) {
|
|
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>${t("error.couldnt_load_backends")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
const visible = providers.filter(
|
|
(p) => !p.Hide && p.Name !== "all" && p.Name !== "alias",
|
|
);
|
|
|
|
function paint(list) {
|
|
if (list.length === 0) {
|
|
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">${t("configure.no_match")}</p>`;
|
|
return;
|
|
}
|
|
grid.innerHTML = list
|
|
.map(
|
|
(p) => `
|
|
<a class="connector-tile" href="#/configure/new/${encodeURIComponent(p.Name)}">
|
|
<span class="tile-name">${escapeHtml(p.Name)}</span>
|
|
<span class="tile-type">${escapeHtml(p.Description || "")}</span>
|
|
</a>`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
paint(visible);
|
|
|
|
filter.addEventListener("input", () => {
|
|
const q = filter.value.trim().toLowerCase();
|
|
if (!q) return paint(visible);
|
|
paint(
|
|
visible.filter((p) =>
|
|
(p.Name + " " + (p.Description || "")).toLowerCase().includes(q),
|
|
),
|
|
);
|
|
});
|
|
filter.focus();
|
|
}
|
|
|
|
// --- Dynamic form ---
|
|
|
|
async function renderForm({ provider, mode, remoteName = "", currentValues = {} }) {
|
|
const app = document.getElementById("app");
|
|
|
|
const isEdit = mode === "edit";
|
|
const requiresOAuth = provider.Options.some(
|
|
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
|
|
);
|
|
const blocksCreate = requiresOAuth && !isEdit;
|
|
|
|
// Partition options into required, optional-basic, optional-advanced.
|
|
const required = provider.Options.filter((o) => o.Required && !o.Hide);
|
|
const basic = provider.Options.filter((o) => !o.Required && !o.Advanced && !o.Hide);
|
|
const advanced = provider.Options.filter((o) => o.Advanced && !o.Hide);
|
|
|
|
app.innerHTML = `
|
|
<div class="section-head">
|
|
<div>
|
|
<h2>${isEdit ? t("configure.edit_title", provider.Name) : t("configure.create_title", provider.Name)}</h2>
|
|
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
|
|
</div>
|
|
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
|
|
</div>
|
|
|
|
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
|
|
${requiresOAuth ? oauthBanner(provider.Name) : ""}
|
|
|
|
<div class="form-grid">
|
|
<div class="field field-full">
|
|
<label>${t("configure.name")} <span class="field-required">*</span></label>
|
|
<input
|
|
class="input"
|
|
name="_remote_name"
|
|
type="text"
|
|
required
|
|
${isEdit ? `value="${escapeHtml(remoteName)}" readonly` : 'placeholder="my-remote"'}
|
|
autocomplete="off"
|
|
>
|
|
</div>
|
|
|
|
${required.length > 0 ? `<div class="form-section-title">${t("configure.section.required")}</div>` : ""}
|
|
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
|
|
|
${basic.length > 0 ? `<div class="form-section-title">${t("configure.section.options")}</div>` : ""}
|
|
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
|
|
|
${advanced.length > 0 ? `
|
|
<div class="advanced-toggle-wrap">
|
|
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
|
|
${t("configure.show_advanced", advanced.length)}
|
|
</button>
|
|
</div>
|
|
<div id="advanced-section" class="advanced-section hidden">
|
|
<div class="form-section-title">${t("configure.section.advanced")}</div>
|
|
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
|
|
${isEdit ? t("configure.save") : t("configure.create")}
|
|
</button>
|
|
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
|
|
</div>
|
|
|
|
${isEdit ? `
|
|
<div class="danger-zone">
|
|
<h4>${t("configure.delete_section_title")}</h4>
|
|
<p>${t("configure.delete_section_body", escapeHtml(remoteName))}</p>
|
|
<button type="button" class="btn btn-danger" id="delete-btn">${t("configure.delete_btn")}</button>
|
|
</div>
|
|
` : ""}
|
|
</form>
|
|
`;
|
|
|
|
const form = document.getElementById("remote-form");
|
|
|
|
// Wire up "Custom…" reveal on <select> fields that have it.
|
|
wireCustomSelects(form);
|
|
|
|
// Advanced toggle
|
|
const advToggle = document.getElementById("advanced-toggle");
|
|
const advSection = document.getElementById("advanced-section");
|
|
if (advToggle && advSection) {
|
|
advToggle.addEventListener("click", () => {
|
|
const hidden = advSection.classList.toggle("hidden");
|
|
advToggle.textContent = hidden
|
|
? t("configure.show_advanced", advanced.length)
|
|
: t("configure.hide_advanced", advanced.length);
|
|
});
|
|
}
|
|
|
|
const deleteBtn = document.getElementById("delete-btn");
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener("click", async () => {
|
|
if (!confirm(t("remotes.delete_confirm", remoteName))) return;
|
|
try {
|
|
await post("config/delete", { name: remoteName });
|
|
toast(t("remotes.deleted", remoteName), "success");
|
|
location.hash = "#/remotes";
|
|
} catch (e) {
|
|
toast(`${t("remotes.delete_failed")}: ${e.message}`, "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
if (blocksCreate) return;
|
|
|
|
const name = form.elements._remote_name.value.trim();
|
|
if (!name) return;
|
|
|
|
const parameters = collectParameters(form, provider.Options, isEdit);
|
|
|
|
try {
|
|
const body = {
|
|
name,
|
|
parameters,
|
|
opt: { nonInteractive: true },
|
|
};
|
|
if (!isEdit) body.type = provider.Name;
|
|
|
|
const endpoint = isEdit ? "config/update" : "config/create";
|
|
await post(endpoint, body);
|
|
toast(isEdit ? t("configure.updated", name) : t("configure.created", name), "success");
|
|
location.hash = "#/remotes";
|
|
} catch (err) {
|
|
toast(`${t("configure.save_failed")}: ${err.message}`, "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function oauthBanner(providerName) {
|
|
return `
|
|
<div class="banner banner-warning">
|
|
<div>
|
|
<strong>${t("configure.oauth_title")}</strong>
|
|
${t("configure.oauth_body", escapeHtml(providerName))}
|
|
<code>rclone config</code>
|
|
${t("configure.oauth_hint")}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function fieldHtml(opt, currentValues, isEdit) {
|
|
const isFull = opt.Type === "bool";
|
|
const classes = ["field"];
|
|
if (isFull) classes.push("field-bool");
|
|
if (opt.Examples && opt.Examples.length > 0 && opt.Type !== "bool") {
|
|
// Examples go full-width to fit the dropdown + custom override
|
|
classes.push("field-full");
|
|
} else if (opt.Help && opt.Help.length > 60) {
|
|
classes.push("field-full");
|
|
}
|
|
const labelHtml = `${escapeHtml(opt.Name)}${opt.Required ? '<span class="field-required">*</span>' : ""}`;
|
|
const helpHtml = opt.Help ? `<span class="field-help">${escapeHtml(opt.Help)}</span>` : "";
|
|
const inputHtml = inputHtmlFor(opt, currentValues[opt.Name], isEdit);
|
|
|
|
return `
|
|
<div class="${classes.join(" ")}">
|
|
<label>${labelHtml}</label>
|
|
${inputHtml}
|
|
${helpHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function inputHtmlFor(opt, value, isEdit) {
|
|
// In edit mode, never pre-fill password fields — server returns them
|
|
// already obscured and resubmitting would double-obscure. Show empty
|
|
// with a "(unchanged)" placeholder.
|
|
if (opt.IsPassword && isEdit) {
|
|
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="${t("configure.password_unchanged")}">`;
|
|
}
|
|
|
|
const name = `opt_${escapeHtml(opt.Name)}`;
|
|
const currentValue = value != null ? String(value) : "";
|
|
|
|
if (opt.Type === "bool") {
|
|
const checked = value === true || value === "true" ? "checked" : "";
|
|
return `<input type="checkbox" name="${name}" ${checked}>`;
|
|
}
|
|
|
|
if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") {
|
|
return `<input class="input" name="${name}" type="number" value="${escapeAttr(currentValue)}" autocomplete="off">`;
|
|
}
|
|
|
|
if (opt.IsPassword) {
|
|
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="${t("configure.password_secret")}">`;
|
|
}
|
|
|
|
if (opt.Examples && opt.Examples.length > 0) {
|
|
const opts = [`<option value="">${t("configure.example_pick")}</option>`]
|
|
.concat(
|
|
opt.Examples.map(
|
|
(ex) =>
|
|
`<option value="${escapeAttr(ex.Value)}"${ex.Value === currentValue ? " selected" : ""}>${escapeHtml(ex.Help || ex.Value)}${ex.Provider ? ` (${escapeHtml(ex.Provider)})` : ""}</option>`,
|
|
),
|
|
)
|
|
.join("");
|
|
return `
|
|
<select class="select" name="${name}" data-has-custom="1">
|
|
${opts}
|
|
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>${t("configure.example_custom")}</option>
|
|
</select>
|
|
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="${t("configure.example_custom_placeholder")}" autocomplete="off">
|
|
`;
|
|
}
|
|
|
|
return `<input class="input" name="${name}" type="text" value="${escapeAttr(currentValue)}" autocomplete="off">`;
|
|
}
|
|
|
|
function collectParameters(form, options, isEdit) {
|
|
const params = {};
|
|
|
|
for (const opt of options) {
|
|
if (opt.Hide) continue;
|
|
const baseName = `opt_${opt.Name}`;
|
|
const el = form.elements[baseName];
|
|
if (!el) continue;
|
|
|
|
let val;
|
|
if (opt.Type === "bool") {
|
|
val = el.checked ? "true" : "false";
|
|
} else if (el.dataset && el.dataset.hasCustom === "1" || el.getAttribute("data-has-custom") === "1") {
|
|
// It's a <select> with a sibling text override
|
|
if (el.value === "__custom__") {
|
|
const customEl = form.elements[`${baseName}__custom`];
|
|
val = customEl ? customEl.value.trim() : "";
|
|
} else {
|
|
val = el.value;
|
|
}
|
|
} else {
|
|
val = el.value.trim();
|
|
}
|
|
|
|
// In edit mode, skip empty password fields (leave unchanged).
|
|
if (isEdit && opt.IsPassword && val === "") continue;
|
|
// Skip empty non-required fields.
|
|
if (val === "" && !opt.Required) continue;
|
|
|
|
params[opt.Name] = val;
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
// Wire up the "Custom…" reveal on selects with data-has-custom.
|
|
// Called from app.js after every form render via a MutationObserver-
|
|
// free approach: we attach the listener at form-render time inside
|
|
// renderForm. Use event delegation for simplicity.
|
|
export function wireCustomSelects(container) {
|
|
container.querySelectorAll('select[data-has-custom="1"]').forEach((sel) => {
|
|
const customName = sel.name + "__custom";
|
|
const customEl = container.elements
|
|
? container.elements[customName]
|
|
: container.querySelector(`[name="${CSS.escape(customName)}"]`);
|
|
if (!customEl) return;
|
|
const sync = () => {
|
|
customEl.style.display = sel.value === "__custom__" ? "block" : "none";
|
|
};
|
|
sync();
|
|
sel.addEventListener("change", sync);
|
|
});
|
|
}
|
|
|
|
async function ensureProviders() {
|
|
const cached = getState().providers;
|
|
if (cached) return cached;
|
|
const res = await post("config/providers");
|
|
const providers = (res && res.providers) || [];
|
|
setState({ providers });
|
|
return providers;
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return escapeHtml(s);
|
|
}
|