Files
MicroNAS/static/js/shares.js
T
marcandClaude Sonnet 4.6 0d09882268 Add extra SMB/NFS settings to edit modal
SMB: force user, force group, guest ok fields + dropdowns for owner/group
NFS: no_root_squash and all_squash toggles exposed in edit modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 00:55:40 +02:00

337 lines
16 KiB
JavaScript

async function loadShares() {
const res = await apiFetch('/api/shares');
if (!res) return;
const shares = await res.json();
const sb = document.getElementById('smbTableBody');
if (!shares.smb?.length) {
sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No SMB shares.</td></tr>`;
} else {
sb.innerHTML = shares.smb.map(s => {
const enabled = s.enabled !== false;
const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
// esc() HTML-encodes JSON so " inside onclick="..." doesn't break the attribute
const shareJson = esc(JSON.stringify(s));
const nameJson = esc(JSON.stringify(s.name));
return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
<td>${dot}</td>
<td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('smb',${shareJson})">Edit</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('smb',${nameJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('smb',${nameJson})">Delete</button>
</td>
</tr>`;
}).join('');
}
const nb = document.getElementById('nfsTableBody');
if (!shares.nfs?.length) {
nb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No NFS exports.</td></tr>`;
} else {
nb.innerHTML = shares.nfs.map(s => {
const enabled = s.enabled !== false;
const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
const shareJson = esc(JSON.stringify(s));
const pathJson = esc(JSON.stringify(s.path));
return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
<td>${dot}</td>
<td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('nfs',${shareJson})">Edit</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('nfs',${pathJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('nfs',${pathJson})">Delete</button>
</td>
</tr>`;
}).join('');
}
}
async function addSMBShare() {
const name = document.getElementById('smbName').value.trim();
const path = document.getElementById('smbPath').value.trim();
const validUsers = document.getElementById('smbUsers').value.trim();
if (!name || !path) return showToast('Name and path are required.', 'danger');
const res = await apiFetch('/api/share/smb', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, validUsers}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast('SMB share created.');
document.getElementById('smbName').value = '';
document.getElementById('smbPath').value = '';
document.getElementById('smbUsers').value = '';
loadShares(); loadDashboard();
}
async function addNFSShare() {
const path = document.getElementById('nfsPath').value.trim();
const clients = document.getElementById('nfsClients').value.trim() || '*';
const readOnly = document.getElementById('nfsReadOnly').checked;
if (!path) return showToast('Path is required.', 'danger');
const res = await apiFetch('/api/share/nfs', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path, clients, readOnly}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast('NFS export created.');
document.getElementById('nfsPath').value = '';
document.getElementById('nfsClients').value = '';
document.getElementById('nfsReadOnly').checked = false;
loadShares(); loadDashboard();
}
// ===== EDIT =====
let _editCtx = null;
async function openEditModal(type, share) {
_editCtx = { type, original: share };
document.getElementById('editModalTitle').textContent =
type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`;
document.getElementById('editModalBody').innerHTML =
`<div style="color:var(--muted); padding:.5rem 0">Loading…</div>`;
new bootstrap.Modal(document.getElementById('editModal')).show();
const [ur, gr] = await Promise.all([apiFetch('/api/users'), apiFetch('/api/groups')]);
const users = ur ? await ur.json() : [];
const groups = gr ? await gr.json() : [];
const ownerNames = ['root', ...users.map(u => u.name)];
const ownerOptions = ownerNames
.map(n => `<option value="${esc(n)}"${share.owner === n ? ' selected' : ''}>${esc(n)}</option>`)
.join('');
const groupOptions = groups
.map(g => `<option value="${esc(g.name)}"${share.group === g.name ? ' selected' : ''}>${esc(g.name)}</option>`)
.join('');
const permSection = `
<hr style="border-color:var(--border); margin:1.25rem 0 1rem">
<div class="fw-semibold mb-3" style="font-size:.9rem; color:var(--text)">Folder Permissions</div>
<div class="row g-2 mb-3">
<div class="col-5">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Owner</label>
<select id="editOwner" class="form-control">
<option value="">— no change —</option>
${ownerOptions}
</select>
</div>
<div class="col-5">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Group</label>
<select id="editGroup" class="form-control">
<option value="">— no change —</option>
${groupOptions}
</select>
</div>
<div class="col-2">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Mode</label>
<input type="text" id="editMode" class="form-control" value="${esc(share.mode || '')}" placeholder="770" maxlength="4">
</div>
</div>
<div style="font-size:.8rem; color:var(--muted)">
Common: <code>770</code> owner+group rw &nbsp;·&nbsp;
<code>750</code> group read-only &nbsp;·&nbsp;
<code>755</code> world read &nbsp;·&nbsp;
Use <code>@groupname</code> in Valid Users to allow a whole group.
</div>`;
let body = '';
if (type === 'smb') {
body = `
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Share Name</label>
<input type="text" id="editName" class="form-control" value="${esc(share.name)}">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
<input type="text" id="editPath" class="form-control" value="${esc(share.path)}">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Valid Users
<span style="font-weight:400"> — space or comma separated, use <code>@groupname</code> for a group</span>
</label>
<input type="text" id="editUsers" class="form-control" value="${esc(share.valid_users || '')}">
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Force User
<span style="font-weight:400"> — all writes appear as this user</span>
</label>
<select id="editForceUser" class="form-control">
<option value="">— none —</option>
${ownerOptions}
</select>
</div>
<div class="col-md-6">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Force Group
<span style="font-weight:400"> — all writes use this group</span>
</label>
<select id="editForceGroup" class="form-control">
<option value="">— none —</option>
${groupOptions}
</select>
</div>
</div>
<div class="form-check ms-1 mb-3">
<input type="checkbox" id="editGuestOk" class="form-check-input" ${share.guest_ok ? 'checked' : ''}>
<label class="form-check-label" for="editGuestOk" style="color:var(--muted)">Guest OK — allow access without a password</label>
</div>
${permSection}`;
} else {
const isRo = /\(ro[,)]/.test(share.clients);
const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim();
body = `
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
<input type="text" class="form-control" value="${esc(share.path)}" readonly style="color:var(--muted)">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Clients</label>
<input type="text" id="editClients" class="form-control" value="${esc(cleanClients)}">
</div>
<div class="form-check ms-1 mt-2 mb-1">
<input type="checkbox" id="editReadOnly" class="form-check-input" ${isRo ? 'checked' : ''}>
<label class="form-check-label" for="editReadOnly" style="color:var(--muted)">Read only</label>
</div>
<div class="form-check ms-1 mb-1">
<input type="checkbox" id="editNoRootSquash" class="form-check-input" ${share.no_root_squash ? 'checked' : ''}>
<label class="form-check-label" for="editNoRootSquash" style="color:var(--muted)">no_root_squash — root on client keeps root access on server</label>
</div>
<div class="form-check ms-1 mb-3">
<input type="checkbox" id="editAllSquash" class="form-check-input" ${share.all_squash ? 'checked' : ''}>
<label class="form-check-label" for="editAllSquash" style="color:var(--muted)">all_squash — map all clients to anonymous user (anonuid)</label>
</div>
${permSection}`;
}
document.getElementById('editModalBody').innerHTML = body;
}
async function submitEdit() {
const ctx = _editCtx;
if (!ctx) return;
let res;
if (ctx.type === 'smb') {
const name = document.getElementById('editName').value.trim();
const path = document.getElementById('editPath').value.trim();
const validUsers = document.getElementById('editUsers').value.trim();
const forceUser = document.getElementById('editForceUser').value;
const forceGroup = document.getElementById('editForceGroup').value;
const guestOk = document.getElementById('editGuestOk').checked;
res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, validUsers, forceUser, forceGroup, guestOk}),
});
} else {
const clients = document.getElementById('editClients').value.trim();
const readOnly = document.getElementById('editReadOnly').checked;
const noRootSquash = document.getElementById('editNoRootSquash').checked;
const allSquash = document.getElementById('editAllSquash').checked;
res = await apiFetch('/api/share/nfs', {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: ctx.original.path, clients, readOnly, noRootSquash, allSquash}),
});
}
if (!res) return;
const data = await res.json();
if (!res.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
return showToast('Error: ' + (data.error || res.status), 'danger');
}
// Apply permissions if any field is filled
const owner = document.getElementById('editOwner')?.value.trim();
const group = document.getElementById('editGroup')?.value.trim();
const mode = document.getElementById('editMode')?.value.trim();
const permPath = ctx.type === 'smb'
? document.getElementById('editPath')?.value.trim()
: ctx.original.path;
if (owner || group || mode) {
const pr = await apiFetch('/api/permissions', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: permPath, owner, group, mode}),
});
if (pr && !pr.ok) {
const pd = await pr.json();
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
return showToast('Permission error: ' + (pd.error || pr.status), 'danger');
}
}
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
showToast('Share updated.');
loadShares(); loadDashboard();
}
// ===== DELETE =====
function deleteShare(type, identifier) {
const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`;
document.getElementById('deleteModalMsg').textContent =
`Are you sure you want to delete ${label}? This cannot be undone.`;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
const btn = document.getElementById('deleteModalConfirm');
const handler = async () => {
btn.removeEventListener('click', handler);
modal.hide();
let res;
if (type === 'smb') {
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
} else {
res = await apiFetch('/api/share/nfs', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: identifier}),
});
}
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`${label} deleted.`);
loadShares(); loadDashboard();
};
btn.addEventListener('click', handler);
modal.show();
}
// ===== TOGGLE =====
async function toggleShare(type, identifier, enable) {
let res;
if (type === 'smb') {
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}/toggle`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enable}),
});
} else {
res = await apiFetch('/api/share/nfs/toggle', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: identifier, enabled: enable}),
});
}
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`Share ${enable ? 'enabled' : 'disabled'}.`);
loadShares(); loadDashboard();
}