mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
Community plugin admin UI: policy + WASM packages
This commit is contained in:
parent
0c99fa253d
commit
e16a36f13c
1 changed files with 584 additions and 19 deletions
|
|
@ -117,6 +117,277 @@ const { slug } = Astro.params;
|
|||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchPluginPolicy(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-policy`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(err || 'Failed to load plugin policy');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchPluginPackages(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-packages`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(err || 'Failed to load plugin packages');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function normalizeStringList(text) {
|
||||
return String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map(v => v.trim())
|
||||
.filter(v => v.length > 0);
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function renderPolicyEditor(policy) {
|
||||
const trust = policy?.trust_policy || 'signed_only';
|
||||
const installSources = new Set(policy?.install_sources || []);
|
||||
const allowOutbound = !!policy?.allow_outbound_http;
|
||||
const httpAllowlist = (policy?.http_egress_allowlist || []).join('\n');
|
||||
const registryAllowlist = (policy?.registry_allowlist || []).join('\n');
|
||||
const allowBg = !!policy?.allow_background_jobs;
|
||||
const publishers = (policy?.trusted_publishers || []).join('\n');
|
||||
|
||||
return `
|
||||
<section class="ui-card policy-card">
|
||||
<div class="policy-head">
|
||||
<h2>Plugin Policy</h2>
|
||||
<div class="policy-actions">
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="save-policy">Save Policy</button>
|
||||
<span class="status" id="policy-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="policy-trust">Trust policy</label>
|
||||
<select id="policy-trust">
|
||||
<option value="signed_only" ${trust === 'signed_only' ? 'selected' : ''}>Signed only</option>
|
||||
<option value="unsigned_allowed" ${trust === 'unsigned_allowed' ? 'selected' : ''}>Unsigned allowed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Install sources</label>
|
||||
<div class="policy-checkboxes">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-source-upload" ${installSources.has('upload') ? 'checked' : ''} />
|
||||
<span>Upload</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-source-registry" ${installSources.has('registry') ? 'checked' : ''} />
|
||||
<span>Registry</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-allow-outbound" ${allowOutbound ? 'checked' : ''} />
|
||||
<span>Allow outbound HTTP (WASM)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-http-allowlist">HTTP egress allowlist (one per line)</label>
|
||||
<textarea id="policy-http-allowlist" spellcheck="false" rows="4">${escapeHtml(httpAllowlist)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-registry-allowlist">Registry allowlist (one host per line; exact or *.suffix)</label>
|
||||
<textarea id="policy-registry-allowlist" spellcheck="false" rows="4">${escapeHtml(registryAllowlist)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-trusted-publishers">Trusted publishers (one Ed25519 key per line)</label>
|
||||
<textarea id="policy-trusted-publishers" spellcheck="false" rows="4">${escapeHtml(publishers)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-allow-background" ${allowBg ? 'checked' : ''} />
|
||||
<span>Allow background jobs (WASM cron hooks)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function packageSettingsSchema(pkg) {
|
||||
const schema = pkg?.manifest?.settings_schema;
|
||||
if (schema && schema.properties) return schema;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPackageSettingsForm(pkg) {
|
||||
const schema = packageSettingsSchema(pkg);
|
||||
const name = String(pkg?.package_id || '');
|
||||
const settings = pkg?.settings || {};
|
||||
if (!schema) {
|
||||
const settingsText = JSON.stringify(settings ?? {}, null, 2);
|
||||
return `
|
||||
<div class="settings-body ui-form" style="--ui-form-group-mb: 0.75rem;">
|
||||
<p class="settings-hint">No schema defined. Edit raw JSON:</p>
|
||||
<textarea class="settings-json" spellcheck="false" data-package-id="${escapeHtml(name)}">${escapeHtml(settingsText)}</textarea>
|
||||
<div class="settings-actions">
|
||||
<button class="ui-btn ui-btn-primary js-save-package-json" data-package-id="${escapeHtml(name)}">Save Settings</button>
|
||||
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const fields = Object.entries(schema.properties).map(([key, propSchema]) => {
|
||||
return renderSchemaField(`pkg-${name}`, key, propSchema, settings?.[key]);
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<form class="settings-form ui-form pkg-settings-form" data-package-id="${escapeHtml(name)}" style="--ui-form-group-mb: 1rem;">
|
||||
${fields}
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="ui-btn ui-btn-primary">Save Settings</button>
|
||||
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPackages(packages) {
|
||||
const list = Array.isArray(packages) ? packages : [];
|
||||
|
||||
return `
|
||||
<section class="ui-card packages-card">
|
||||
<div class="packages-head">
|
||||
<h2>WASM Plugin Packages</h2>
|
||||
<div class="packages-actions">
|
||||
<button type="button" class="ui-btn ui-btn-secondary" id="refresh-packages">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="registry-url">Install from registry URL</label>
|
||||
<div class="inline-row">
|
||||
<input type="url" id="registry-url" placeholder="https://registry.example/plugin-bundle.json" />
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="install-registry">Install</button>
|
||||
</div>
|
||||
<span class="status" id="registry-status"></span>
|
||||
</div>
|
||||
|
||||
<details class="panel ui-card" open>
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-title">Upload package</span>
|
||||
<span class="panel-meta"><span class="ui-badge">WASM</span></span>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<form id="upload-form" class="ui-form" style="--ui-form-group-mb: 0.75rem;">
|
||||
<div class="form-group">
|
||||
<label for="upload-name">Name</label>
|
||||
<input id="upload-name" type="text" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-version">Version</label>
|
||||
<input id="upload-version" type="text" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-description">Description</label>
|
||||
<input id="upload-description" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-publisher">Publisher (Ed25519 public key, base64)</label>
|
||||
<input id="upload-publisher" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-signature">Signature (base64, optional)</label>
|
||||
<input id="upload-signature" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-manifest">Manifest (JSON)</label>
|
||||
<textarea id="upload-manifest" spellcheck="false" rows="6" required>{\n "name": "",\n "version": "",\n "description": "",\n "hooks": [],\n "capabilities": []\n}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-wasm">WASM file</label>
|
||||
<input id="upload-wasm" type="file" accept=".wasm,application/wasm" required />
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="ui-btn ui-btn-primary" id="upload-btn">Upload</button>
|
||||
<span class="status" id="upload-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="packages-list">
|
||||
${list.length === 0 ? `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No packages installed.</p>
|
||||
<p class="hint">Install from registry or upload a WASM bundle.</p>
|
||||
</div>
|
||||
` : list.map(pkg => {
|
||||
const pkgId = String(pkg.package_id);
|
||||
const title = `${escapeHtml(pkg.name)} <span class="version">v${escapeHtml(pkg.version)}</span>`;
|
||||
return `
|
||||
<div class="plugin-card ui-card" data-package-id="${escapeHtml(pkgId)}">
|
||||
<div class="plugin-head">
|
||||
<div class="plugin-title">
|
||||
<h3>${title}</h3>
|
||||
${pkg.signature_present ? `<span class="ui-pill ui-pill-core">signed</span>` : `<span class="ui-pill ui-pill-disabled">unsigned</span>`}
|
||||
${pkg.source ? `<span class="ui-pill">${escapeHtml(pkg.source)}</span>` : ''}
|
||||
</div>
|
||||
<div class="plugin-controls">
|
||||
<label class="switch" title="Activate/deactivate">
|
||||
<input class="package-toggle" type="checkbox" data-package-id="${escapeHtml(pkgId)}" ${pkg.is_active ? 'checked' : ''} />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc">${escapeHtml(pkg.description || '')}</p>
|
||||
<div class="pkg-meta">
|
||||
<div><span class="k">publisher</span> <span class="v">${escapeHtml(pkg.publisher || '')}</span></div>
|
||||
<div><span class="k">sha256</span> <span class="v">${escapeHtml(pkg.wasm_sha256 || '')}</span></div>
|
||||
${pkg.registry_url ? `<div><span class="k">registry</span> <span class="v">${escapeHtml(pkg.registry_url)}</span></div>` : ''}
|
||||
</div>
|
||||
<details class="panel ui-card settings-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-title">Settings</span>
|
||||
<span class="panel-meta">${packageSettingsSchema(pkg) ? 'Schema' : '<span class="ui-badge">JSON</span>'}</span>
|
||||
</summary>
|
||||
<div class="panel-body settings-body-wrap">
|
||||
${renderPackageSettingsForm(pkg)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchPlugins(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
|
|
@ -207,7 +478,7 @@ const { slug } = Astro.params;
|
|||
`;
|
||||
}
|
||||
|
||||
function renderPlugins(community, membership, plugins) {
|
||||
function renderPlugins(community, membership, policy, packages, plugins) {
|
||||
const container = document.getElementById('plugins-content');
|
||||
if (!container) return;
|
||||
|
||||
|
|
@ -218,21 +489,7 @@ const { slug } = Astro.params;
|
|||
return;
|
||||
}
|
||||
|
||||
if (!plugins || plugins.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No plugins available.</p>
|
||||
<p class="hint">If this seems wrong, try reloading the page.</p>
|
||||
<div class="state-actions">
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="retry-plugins-empty">Retry</button>
|
||||
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug || ''))}">Back to community</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('retry-plugins-empty')?.addEventListener('click', load);
|
||||
return;
|
||||
}
|
||||
const safePlugins = Array.isArray(plugins) ? plugins : [];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="community-badge ui-card">
|
||||
|
|
@ -243,8 +500,16 @@ const { slug } = Astro.params;
|
|||
<div class="community-badge-subtitle">Community plugin settings</div>
|
||||
</div>
|
||||
|
||||
${renderPolicyEditor(policy)}
|
||||
|
||||
${renderPackages(packages)}
|
||||
|
||||
<div class="plugins-list">
|
||||
${plugins.map(p => {
|
||||
${safePlugins.length === 0 ? `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No built-in plugins available.</p>
|
||||
</div>
|
||||
` : safePlugins.map(p => {
|
||||
const disabledGlobally = !p.global_is_active;
|
||||
const isCore = !!p.is_core;
|
||||
const checked = !!p.community_is_active;
|
||||
|
|
@ -286,6 +551,296 @@ const { slug } = Astro.params;
|
|||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('refresh-packages')?.addEventListener('click', load);
|
||||
|
||||
document.getElementById('save-policy')?.addEventListener('click', async () => {
|
||||
const statusEl = document.getElementById('policy-status');
|
||||
const btn = document.getElementById('save-policy');
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const current = {
|
||||
trust_policy: (document.getElementById('policy-trust'))?.value,
|
||||
install_sources: [
|
||||
(document.getElementById('policy-source-upload'))?.checked ? 'upload' : null,
|
||||
(document.getElementById('policy-source-registry'))?.checked ? 'registry' : null,
|
||||
].filter(Boolean),
|
||||
allow_outbound_http: (document.getElementById('policy-allow-outbound'))?.checked,
|
||||
http_egress_allowlist: normalizeStringList((document.getElementById('policy-http-allowlist'))?.value),
|
||||
registry_allowlist: normalizeStringList((document.getElementById('policy-registry-allowlist'))?.value),
|
||||
trusted_publishers: normalizeStringList((document.getElementById('policy-trusted-publishers'))?.value),
|
||||
allow_background_jobs: (document.getElementById('policy-allow-background'))?.checked,
|
||||
};
|
||||
|
||||
const patch = {};
|
||||
if (!policy || current.trust_policy !== policy.trust_policy) patch.trust_policy = current.trust_policy;
|
||||
if (!policy || JSON.stringify(current.install_sources) !== JSON.stringify(policy.install_sources || [])) patch.install_sources = current.install_sources;
|
||||
if (!policy || current.allow_outbound_http !== !!policy.allow_outbound_http) patch.allow_outbound_http = current.allow_outbound_http;
|
||||
if (!policy || JSON.stringify(current.http_egress_allowlist) !== JSON.stringify(policy.http_egress_allowlist || [])) patch.http_egress_allowlist = current.http_egress_allowlist;
|
||||
if (!policy || JSON.stringify(current.registry_allowlist) !== JSON.stringify(policy.registry_allowlist || [])) patch.registry_allowlist = current.registry_allowlist;
|
||||
if (!policy || JSON.stringify(current.trusted_publishers) !== JSON.stringify(policy.trusted_publishers || [])) patch.trusted_publishers = current.trusted_publishers;
|
||||
if (!policy || current.allow_background_jobs !== !!policy.allow_background_jobs) patch.allow_background_jobs = current.allow_background_jobs;
|
||||
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-policy`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to save policy');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('install-registry')?.addEventListener('click', async () => {
|
||||
const input = document.getElementById('registry-url');
|
||||
const statusEl = document.getElementById('registry-status');
|
||||
const btn = document.getElementById('install-registry');
|
||||
const url = input?.value;
|
||||
|
||||
if (!url) return;
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Installing...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/install-registry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Installed';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
if (input) input.value = '';
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to install from registry');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('upload-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('upload-btn');
|
||||
const statusEl = document.getElementById('upload-status');
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Uploading...';
|
||||
|
||||
try {
|
||||
const name = (document.getElementById('upload-name'))?.value;
|
||||
const version = (document.getElementById('upload-version'))?.value;
|
||||
const description = (document.getElementById('upload-description'))?.value || null;
|
||||
const publisher = (document.getElementById('upload-publisher'))?.value || null;
|
||||
const signature_base64 = (document.getElementById('upload-signature'))?.value || null;
|
||||
const manifestText = (document.getElementById('upload-manifest'))?.value;
|
||||
const wasmFile = (document.getElementById('upload-wasm'))?.files?.[0];
|
||||
|
||||
if (!name || !version || !manifestText || !wasmFile) {
|
||||
throw new Error('Missing required fields');
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestText);
|
||||
} catch {
|
||||
throw new Error('Invalid manifest JSON');
|
||||
}
|
||||
|
||||
const wasmBuf = await wasmFile.arrayBuffer();
|
||||
const wasm_base64 = arrayBufferToBase64(wasmBuf);
|
||||
|
||||
const body = {
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
publisher,
|
||||
manifest,
|
||||
wasm_base64,
|
||||
signature_base64,
|
||||
};
|
||||
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Uploaded';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to upload package');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.package-toggle').forEach(el => {
|
||||
el.addEventListener('change', async (e) => {
|
||||
const input = e.target;
|
||||
const packageId = input.dataset.packageId;
|
||||
const next = input.checked;
|
||||
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ is_active: next }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
input.checked = !next;
|
||||
alert(err || 'Failed to update package');
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
input.checked = !next;
|
||||
alert('Failed to update package');
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-save-package-json').forEach(el => {
|
||||
el.addEventListener('click', async (e) => {
|
||||
const btn = e.target;
|
||||
const packageId = btn.dataset.packageId;
|
||||
const textarea = document.querySelector(`textarea.settings-json[data-package-id="${packageId}"]`);
|
||||
const statusEl = document.getElementById(`pkg-status-${packageId}`);
|
||||
if (!textarea) return;
|
||||
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(textarea.value || '{}');
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Invalid JSON';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.pkg-settings-form').forEach(form => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const packageId = form.dataset.packageId;
|
||||
const statusEl = document.getElementById(`pkg-status-${packageId}`);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
||||
const settings = {};
|
||||
form.querySelectorAll('[data-key]').forEach(input => {
|
||||
const key = input.dataset.key;
|
||||
const type = input.dataset.type;
|
||||
|
||||
if (type === 'boolean') {
|
||||
settings[key] = input.checked;
|
||||
} else if (type === 'integer') {
|
||||
settings[key] = input.value ? parseInt(input.value, 10) : null;
|
||||
} else if (type === 'number') {
|
||||
settings[key] = input.value ? parseFloat(input.value) : null;
|
||||
} else {
|
||||
settings[key] = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.plugin-toggle').forEach(el => {
|
||||
el.addEventListener('change', async (e) => {
|
||||
const input = e.target;
|
||||
|
|
@ -436,9 +991,19 @@ const { slug } = Astro.params;
|
|||
}
|
||||
|
||||
const membership = await fetchMembership(community.id);
|
||||
const plugins = await fetchPlugins(community.id);
|
||||
const allowed = membership?.role === 'admin' || membership?.role === 'moderator';
|
||||
if (!allowed) {
|
||||
renderForbiddenState(community?.name || 'this community');
|
||||
return;
|
||||
}
|
||||
|
||||
renderPlugins(community, membership, plugins);
|
||||
const [policy, packages, plugins] = await Promise.all([
|
||||
fetchPluginPolicy(community.id),
|
||||
fetchPluginPackages(community.id),
|
||||
fetchPlugins(community.id),
|
||||
]);
|
||||
|
||||
renderPlugins(community, membership, policy, packages, plugins);
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Failed to load plugins.';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue