Phase 3: full share CRUD + session login page

Backend:
- SMB config parser/writer (_parse_smb_conf, _write_smb_conf) for
  safe whole-file rewrites instead of append-only
- NFS exports parser/writer (_parse_exports, _write_exports) with
  support for disabled (commented-out) exports
- DELETE/PATCH/toggle endpoints for SMB and NFS shares
- parse_smb() now returns enabled field (reads available = no)
- get_nfs_shares() uses the new parser and returns enabled field
- Replace HTTP Basic Auth with Flask session auth (secret key derived
  from MICRONAS_PASS so sessions survive restarts)
- /login GET/POST, /logout routes

Frontend:
- Login page (templates/login.html) with same dark theme
- Sign out button in header
- Edit modal pre-filled with current share values
- Delete buttons wired up (with confirm dialog)
- Enable/Disable toggle per share (green/grey dot status indicator)
- Disabled shares shown at reduced opacity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-19 22:26:32 +02:00
1 parent 1118681fd2
commit 5d9a9e62ec
4 files changed
+487 -69

No files matched your search

+269 -46
View File
@@ -1,11 +1,12 @@
import os import os
import re import re
import shutil import shutil
import hashlib
import subprocess import subprocess
import functools import functools
from pathlib import Path from pathlib import Path
from flask import Flask, request, jsonify, render_template, Response from flask import Flask, request, jsonify, render_template, Response, session, redirect, url_for
app = Flask(__name__) app = Flask(__name__)
@@ -16,6 +17,11 @@ ALLOWED_PATH_BASE = Path('/srv').resolve()
_AUTH_USER = os.environ.get('MICRONAS_USER', 'admin') _AUTH_USER = os.environ.get('MICRONAS_USER', 'admin')
_AUTH_PASS = os.environ.get('MICRONAS_PASS', '') _AUTH_PASS = os.environ.get('MICRONAS_PASS', '')
# Derive secret key from the password so sessions survive restarts
# but are automatically invalidated if the password changes.
app.secret_key = os.environ.get('MICRONAS_SECRET') or \
hashlib.sha256((_AUTH_PASS or 'insecure-dev-key').encode()).hexdigest()
if not _AUTH_PASS: if not _AUTH_PASS:
import warnings import warnings
warnings.warn( warnings.warn(
@@ -30,18 +36,15 @@ def require_auth(f):
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
if not _AUTH_PASS: if not _AUTH_PASS:
return f(*args, **kwargs) return f(*args, **kwargs)
auth = request.authorization if not session.get('logged_in'):
if not auth or auth.username != _AUTH_USER or auth.password != _AUTH_PASS: if request.path.startswith('/api/'):
return Response( return Response('Unauthorized', 401)
'Unauthorized', 401, return redirect(url_for('login'))
{'WWW-Authenticate': 'Basic realm="micronas"'},
)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
def validate_path(path_str): def validate_path(path_str):
"""Resolve path and ensure it falls under /srv."""
try: try:
p = Path(path_str).resolve() p = Path(path_str).resolve()
except Exception: except Exception:
@@ -52,11 +55,49 @@ def validate_path(path_str):
return str(p) return str(p)
def _validate_users(valid_users):
"""Validate and return a cleaned comma-separated user list, or raise ValueError."""
users = []
for u in re.split(r'[\s,]+', valid_users):
if not u:
continue
if not VALID_NAME.match(u):
raise ValueError(f'invalid username: {u}')
users.append(u)
return ', '.join(users)
# ===== SMB ===== # ===== SMB =====
_SMB_SKIP = {'global', 'homes', 'printers', 'print$', 'ipc$'} _SMB_SKIP = {'global', 'homes', 'printers', 'print$', 'ipc$'}
def _parse_smb_conf():
"""Parse /etc/samba/smb.conf into sections. First entry (name=None) is the preamble."""
sections = []
current = {'name': None, 'lines': []}
try:
with open('/etc/samba/smb.conf', 'r') as f:
for line in f:
m = re.match(r'^\s*\[(.+)\]\s*$', line)
if m:
sections.append(current)
current = {'name': m.group(1).strip(), 'lines': [line]}
else:
current['lines'].append(line)
except FileNotFoundError:
return []
sections.append(current)
return sections
def _write_smb_conf(sections):
with open('/etc/samba/smb.conf', 'w') as f:
for s in sections:
f.writelines(s['lines'])
subprocess.run(['smbcontrol', 'all', 'reload-config'], capture_output=True)
def parse_smb(): def parse_smb():
result = subprocess.run(['testparm', '-s'], capture_output=True, text=True) result = subprocess.run(['testparm', '-s'], capture_output=True, text=True)
shares = [] shares = []
@@ -68,7 +109,7 @@ def parse_smb():
if m: if m:
if current is not None and current['name'].lower() not in _SMB_SKIP: if current is not None and current['name'].lower() not in _SMB_SKIP:
shares.append(current) shares.append(current)
current = {'name': m.group(1), 'path': '', 'valid_users': ''} current = {'name': m.group(1), 'path': '', 'valid_users': '', 'enabled': True}
elif current is not None and '=' in line: elif current is not None and '=' in line:
key, _, val = line.partition('=') key, _, val = line.partition('=')
key = key.strip().lower().replace(' ', '_') key = key.strip().lower().replace(' ', '_')
@@ -77,6 +118,8 @@ def parse_smb():
current['path'] = val current['path'] = val
elif key == 'valid_users': elif key == 'valid_users':
current['valid_users'] = val current['valid_users'] = val
elif key == 'available':
current['enabled'] = val.lower() not in ('no', 'false', '0')
if current is not None and current['name'].lower() not in _SMB_SKIP: if current is not None and current['name'].lower() not in _SMB_SKIP:
shares.append(current) shares.append(current)
@@ -99,22 +142,52 @@ def add_smb_share(name, path, valid_users):
# ===== NFS ===== # ===== NFS =====
def get_nfs_shares(): def _parse_exports():
shares = [] """Parse /etc/exports preserving blanks, comments, and disabled (commented-out) exports."""
entries = []
try: try:
with open('/etc/exports', 'r') as f: with open('/etc/exports', 'r') as f:
for line in f: for raw in f:
line = line.strip() stripped = raw.strip()
if not line or line.startswith('#'): if not stripped:
entries.append({'kind': 'blank', 'raw': raw})
continue continue
parts = line.split() if stripped.startswith('#'):
shares.append({ inner = stripped[1:].strip()
'path': parts[0], if inner.startswith('/'):
'clients': ' '.join(parts[1:]) if len(parts) > 1 else '*', parts = inner.split(None, 1)
}) entries.append({'kind': 'export', 'enabled': False,
'path': parts[0], 'rest': parts[1] if len(parts) > 1 else '',
'raw': raw})
else:
entries.append({'kind': 'comment', 'raw': raw})
continue
parts = stripped.split(None, 1)
entries.append({'kind': 'export', 'enabled': True,
'path': parts[0], 'rest': parts[1] if len(parts) > 1 else '',
'raw': raw})
except FileNotFoundError: except FileNotFoundError:
pass pass
return shares return entries
def _write_exports(entries):
with open('/etc/exports', 'w') as f:
for e in entries:
if e['kind'] == 'export':
prefix = '' if e['enabled'] else '#'
rest = f'\t{e["rest"]}' if e['rest'] else ''
f.write(f'{prefix}{e["path"]}{rest}\n')
else:
f.write(e['raw'])
subprocess.run(['exportfs', '-ra'], check=True)
def get_nfs_shares():
return [
{'path': e['path'], 'clients': e['rest'], 'enabled': e['enabled']}
for e in _parse_exports() if e['kind'] == 'export'
]
def add_nfs_share(path, clients, ro=False): def add_nfs_share(path, clients, ro=False):
@@ -125,7 +198,30 @@ def add_nfs_share(path, clients, ro=False):
subprocess.run(['exportfs', '-ra'], check=True) subprocess.run(['exportfs', '-ra'], check=True)
# ===== ROUTES ===== # ===== AUTH ROUTES =====
@app.route('/login', methods=['GET', 'POST'])
def login():
if session.get('logged_in'):
return redirect(url_for('index'))
error = None
if request.method == 'POST':
username = request.form.get('username', '')
password = request.form.get('password', '')
if not _AUTH_PASS or (username == _AUTH_USER and password == _AUTH_PASS):
session['logged_in'] = True
return redirect(url_for('index'))
error = 'Invalid username or password.'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
# ===== MAIN ROUTES =====
@app.route('/') @app.route('/')
@require_auth @require_auth
@@ -139,6 +235,8 @@ def get_all_shares():
return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()}) return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()})
# ===== SMB CRUD =====
@app.route('/api/share/smb', methods=['POST']) @app.route('/api/share/smb', methods=['POST'])
@require_auth @require_auth
def create_smb(): def create_smb():
@@ -149,22 +247,85 @@ def create_smb():
if not VALID_NAME.match(name): if not VALID_NAME.match(name):
return jsonify({'error': 'invalid share name (letters, digits, _ -)'}), 400 return jsonify({'error': 'invalid share name (letters, digits, _ -)'}), 400
users = []
for u in re.split(r'[\s,]+', valid_users):
if not u:
continue
if not VALID_NAME.match(u):
return jsonify({'error': f'invalid username: {u}'}), 400
users.append(u)
try: try:
clean_users = _validate_users(valid_users)
safe_path = validate_path(path) safe_path = validate_path(path)
except ValueError as e: except ValueError as e:
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
add_smb_share(name, safe_path, ', '.join(users)) add_smb_share(name, safe_path, clean_users)
return jsonify({'type': 'smb', 'status': 'created'}) return jsonify({'type': 'smb', 'status': 'created'})
@app.route('/api/share/smb/<name>', methods=['DELETE'])
@require_auth
def delete_smb(name):
if not VALID_NAME.match(name):
return jsonify({'error': 'invalid share name'}), 400
sections = _parse_smb_conf()
filtered = [s for s in sections if s['name'] != name]
if len(filtered) == len(sections):
return jsonify({'error': f'share {name!r} not found'}), 404
_write_smb_conf(filtered)
return jsonify({'status': 'deleted'})
@app.route('/api/share/smb/<name>', methods=['PATCH'])
@require_auth
def edit_smb(name):
data = request.json or {}
new_name = data.get('name', name).strip()
path = data.get('path', '')
valid_users = data.get('validUsers', '')
if not VALID_NAME.match(new_name):
return jsonify({'error': 'invalid share name'}), 400
try:
clean_users = _validate_users(valid_users)
safe_path = validate_path(path)
except ValueError as e:
return jsonify({'error': str(e)}), 400
sections = _parse_smb_conf()
for s in sections:
if s['name'] == name:
s['name'] = new_name
s['lines'] = [
f'[{new_name}]\n',
f' path = {safe_path}\n',
f' valid users = {clean_users}\n',
' writable = yes\n',
' browseable = yes\n',
]
break
else:
return jsonify({'error': f'share {name!r} not found'}), 404
_write_smb_conf(sections)
return jsonify({'status': 'updated'})
@app.route('/api/share/smb/<name>/toggle', methods=['POST'])
@require_auth
def toggle_smb(name):
if not VALID_NAME.match(name):
return jsonify({'error': 'invalid share name'}), 400
enabled = (request.json or {}).get('enabled', True)
sections = _parse_smb_conf()
for s in sections:
if s['name'] == name:
s['lines'] = [l for l in s['lines'] if not re.match(r'\s*available\s*=', l, re.I)]
if not enabled:
s['lines'].append(' available = no\n')
break
else:
return jsonify({'error': f'share {name!r} not found'}), 404
_write_smb_conf(sections)
return jsonify({'status': 'enabled' if enabled else 'disabled'})
# ===== NFS CRUD =====
@app.route('/api/share/nfs', methods=['POST']) @app.route('/api/share/nfs', methods=['POST'])
@require_auth @require_auth
def create_nfs(): def create_nfs():
@@ -184,6 +345,78 @@ def create_nfs():
return jsonify({'type': 'nfs', 'status': 'created'}) return jsonify({'type': 'nfs', 'status': 'created'})
@app.route('/api/share/nfs', methods=['DELETE'])
@require_auth
def delete_nfs():
path = (request.json or {}).get('path', '')
try:
safe_path = validate_path(path)
except ValueError as e:
return jsonify({'error': str(e)}), 400
entries = _parse_exports()
original = sum(1 for e in entries if e['kind'] == 'export')
entries = [e for e in entries if not (e['kind'] == 'export' and e['path'] == safe_path)]
if sum(1 for e in entries if e['kind'] == 'export') == original:
return jsonify({'error': f'export {safe_path!r} not found'}), 404
_write_exports(entries)
return jsonify({'status': 'deleted'})
@app.route('/api/share/nfs', methods=['PATCH'])
@require_auth
def edit_nfs():
data = request.json or {}
path = data.get('path', '')
clients = data.get('clients', '*')
ro = data.get('readOnly', False)
try:
safe_path = validate_path(path)
except ValueError as e:
return jsonify({'error': str(e)}), 400
if not VALID_NFS_CLIENT.match(clients):
return jsonify({'error': 'invalid client spec'}), 400
options = 'ro' if ro else 'rw'
new_rest = f'{clients}({options},sync,no_subtree_check)'
entries = _parse_exports()
for e in entries:
if e['kind'] == 'export' and e['path'] == safe_path:
e['rest'] = new_rest
break
else:
return jsonify({'error': f'export {safe_path!r} not found'}), 404
_write_exports(entries)
return jsonify({'status': 'updated'})
@app.route('/api/share/nfs/toggle', methods=['POST'])
@require_auth
def toggle_nfs():
data = request.json or {}
path = data.get('path', '')
enabled = data.get('enabled', True)
try:
safe_path = validate_path(path)
except ValueError as e:
return jsonify({'error': str(e)}), 400
entries = _parse_exports()
for e in entries:
if e['kind'] == 'export' and e['path'] == safe_path:
e['enabled'] = enabled
break
else:
return jsonify({'error': f'export {safe_path!r} not found'}), 404
_write_exports(entries)
return jsonify({'status': 'enabled' if enabled else 'disabled'})
# ===== QUOTA =====
@app.route('/api/quota', methods=['POST']) @app.route('/api/quota', methods=['POST'])
@require_auth @require_auth
def set_quota(): def set_quota():
@@ -210,13 +443,9 @@ def set_quota():
except ValueError as e: except ValueError as e:
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
# setquota takes kilobytes; soft = requested, hard = +1 GB buffer
soft_kb = str(quota_gb * 1024 * 1024) soft_kb = str(quota_gb * 1024 * 1024)
hard_kb = str((quota_gb + 1) * 1024 * 1024) hard_kb = str((quota_gb + 1) * 1024 * 1024)
subprocess.run( subprocess.run(['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount], check=True)
['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount],
check=True,
)
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@@ -238,7 +467,7 @@ def _list_users():
parts = line.strip().split(':') parts = line.strip().split(':')
if len(parts) < 4: if len(parts) < 4:
continue continue
name, _, uid = parts[0], parts[1], parts[2] name, uid = parts[0], parts[2]
uid = int(uid) uid = int(uid)
if uid < 1000 or uid == 65534: if uid < 1000 or uid == 65534:
continue continue
@@ -353,18 +582,14 @@ def _shares_with_usage():
result = [] result = []
for s in parse_smb(): for s in parse_smb():
result.append({ result.append({
'type': 'smb', 'type': 'smb', 'name': s['name'], 'path': s['path'],
'name': s['name'], 'access': s['valid_users'], 'enabled': s['enabled'],
'path': s['path'],
'access': s['valid_users'],
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
}) })
for s in get_nfs_shares(): for s in get_nfs_shares():
result.append({ result.append({
'type': 'nfs', 'type': 'nfs', 'name': None, 'path': s['path'],
'name': None, 'access': s['clients'], 'enabled': s['enabled'],
'path': s['path'],
'access': s['clients'],
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
}) })
return result return result
@@ -388,9 +613,7 @@ def _quota_usage():
if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]): if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]):
continue continue
try: try:
used_kb = int(parts[2]) used_kb, soft_kb, hard_kb = int(parts[2]), int(parts[3]), int(parts[4])
soft_kb = int(parts[3])
hard_kb = int(parts[4])
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
if soft_kb == 0 and hard_kb == 0 and used_kb == 0: if soft_kb == 0 and hard_kb == 0 and used_kb == 0:
+154 -20
View File
@@ -5,39 +5,53 @@ async function loadShares() {
const sb = document.getElementById('smbTableBody'); const sb = document.getElementById('smbTableBody');
if (!shares.smb?.length) { if (!shares.smb?.length) {
sb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No SMB shares.</td></tr>`; sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No SMB shares.</td></tr>`;
} else { } else {
sb.innerHTML = shares.smb.map(s => ` sb.innerHTML = shares.smb.map(s => {
<tr> const enabled = s.enabled !== false;
const statusDot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td>${esc(s.name)}</td> <td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td> <td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td> <td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
<td>${statusDot}</td>
<td> <td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openQuotaModal('smb',${JSON.stringify(s.name)},${JSON.stringify(s.path)},${JSON.stringify(s.valid_users)})"> onclick="openEditModal('smb',${JSON.stringify(s)})">Edit</button>
Quota <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
</button> onclick="openQuotaModal('smb',${JSON.stringify(s.name)},${JSON.stringify(s.path)},${JSON.stringify(s.valid_users)})">Quota</button>
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button> <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('smb',${JSON.stringify(s.name)},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('smb',${JSON.stringify(s.name)})">Delete</button>
</td> </td>
</tr>`).join(''); </tr>`;
}).join('');
} }
const nb = document.getElementById('nfsTableBody'); const nb = document.getElementById('nfsTableBody');
if (!shares.nfs?.length) { if (!shares.nfs?.length) {
nb.innerHTML = `<tr><td colspan="3" style="color:var(--muted)">No NFS exports.</td></tr>`; nb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No NFS exports.</td></tr>`;
} else { } else {
nb.innerHTML = shares.nfs.map(s => ` nb.innerHTML = shares.nfs.map(s => {
<tr> const enabled = s.enabled !== false;
const statusDot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td><code>${esc(s.path)}</code></td> <td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td> <td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
<td>${statusDot}</td>
<td> <td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openQuotaModal('nfs',null,${JSON.stringify(s.path)},null)"> onclick="openEditModal('nfs',${JSON.stringify(s)})">Edit</button>
Quota <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
</button> onclick="openQuotaModal('nfs',null,${JSON.stringify(s.path)},null)">Quota</button>
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button> <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('nfs',${JSON.stringify(s.path)},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('nfs',${JSON.stringify(s.path)})">Delete</button>
</td> </td>
</tr>`).join(''); </tr>`;
}).join('');
} }
} }
@@ -58,8 +72,7 @@ async function addSMBShare() {
document.getElementById('smbName').value = ''; document.getElementById('smbName').value = '';
document.getElementById('smbPath').value = ''; document.getElementById('smbPath').value = '';
document.getElementById('smbUsers').value = ''; document.getElementById('smbUsers').value = '';
loadShares(); loadShares(); loadDashboard();
loadDashboard();
} }
async function addNFSShare() { async function addNFSShare() {
@@ -79,6 +92,127 @@ async function addNFSShare() {
document.getElementById('nfsPath').value = ''; document.getElementById('nfsPath').value = '';
document.getElementById('nfsClients').value = ''; document.getElementById('nfsClients').value = '';
document.getElementById('nfsReadOnly').checked = false; document.getElementById('nfsReadOnly').checked = false;
loadShares(); loadShares(); loadDashboard();
loadDashboard(); }
// ===== EDIT =====
let _editCtx = null;
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)}`;
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</label>
<input type="text" id="editUsers" class="form-control" value="${esc(share.valid_users || '')}">
</div>`;
} 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">
<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>`;
}
document.getElementById('editModalBody').innerHTML = body;
new bootstrap.Modal(document.getElementById('editModal')).show();
}
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();
res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, validUsers}),
});
} else {
const clients = document.getElementById('editClients').value.trim();
const readOnly = document.getElementById('editReadOnly').checked;
res = await apiFetch('/api/share/nfs', {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: ctx.original.path, clients, readOnly}),
});
}
if (!res) return;
const data = await res.json();
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast('Share updated.');
loadShares(); loadDashboard();
}
// ===== DELETE =====
async function deleteShare(type, identifier) {
const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`;
if (!confirm(`Delete ${label}? This cannot be undone.`)) return;
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();
}
// ===== 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();
} }
+23 -3
View File
@@ -48,10 +48,30 @@
</div> </div>
</div> </div>
<!-- Edit Share modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editModalTitle">Edit Share</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="editModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitEdit()">Save</button>
</div>
</div>
</div>
</div>
<!-- Header --> <!-- Header -->
<div class="topbar d-flex justify-content-between align-items-center"> <div class="topbar d-flex justify-content-between align-items-center">
<span class="fw-semibold fs-5">MicroNAS</span> <span class="fw-semibold fs-5">MicroNAS</span>
<span id="lastRefresh" class="small" style="color:var(--muted)"></span> <div class="d-flex align-items-center gap-3">
<span id="lastRefresh" class="small" style="color:var(--muted)"></span>
<a href="/logout" class="btn btn-sm" style="border:1px solid var(--border); color:var(--muted)">Sign out</a>
</div>
</div> </div>
<div class="container-lg py-4"> <div class="container-lg py-4">
@@ -147,7 +167,7 @@
<div class="fw-semibold mb-2">Shares</div> <div class="fw-semibold mb-2">Shares</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
<thead><tr><th>Name</th><th>Path</th><th>Valid Users</th><th>Actions</th></tr></thead> <thead><tr><th>Name</th><th>Path</th><th>Valid Users</th><th></th><th>Actions</th></tr></thead>
<tbody id="smbTableBody"> <tbody id="smbTableBody">
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr> <tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
</tbody> </tbody>
@@ -172,7 +192,7 @@
<div class="fw-semibold mb-2">Exports</div> <div class="fw-semibold mb-2">Exports</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
<thead><tr><th>Path</th><th>Clients</th><th>Actions</th></tr></thead> <thead><tr><th>Path</th><th>Clients</th><th></th><th>Actions</th></tr></thead>
<tbody id="nfsTableBody"> <tbody id="nfsTableBody">
<tr><td colspan="3" style="color:var(--muted)">Loading…</td></tr> <tr><td colspan="3" style="color:var(--muted)">Loading…</td></tr>
</tbody> </tbody>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MicroNAS — Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { width: 100%; max-width: 360px; }
</style>
</head>
<body>
<div class="login-card">
<div class="text-center mb-4">
<div class="fw-bold fs-4" style="color:var(--text)">MicroNAS</div>
<div class="small" style="color:var(--muted)">Sign in to continue</div>
</div>
<div class="card">
<div class="card-body p-4">
{% if error %}
<div class="alert mb-3 py-2 px-3" style="background:#2d1515; border:1px solid var(--red); color:var(--red); border-radius:6px; font-size:.9rem;">
{{ error }}
</div>
{% endif %}
<form method="POST">
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Username</label>
<input type="text" name="username" class="form-control" autofocus autocomplete="username">
</div>
<div class="mb-4">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Password</label>
<input type="password" name="password" class="form-control" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</form>
</div>
</div>
</div>
</body>
</html>