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 re
import shutil
import hashlib
import subprocess
import functools
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__)
@@ -16,6 +17,11 @@ ALLOWED_PATH_BASE = Path('/srv').resolve()
_AUTH_USER = os.environ.get('MICRONAS_USER', 'admin')
_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:
import warnings
warnings.warn(
@@ -30,18 +36,15 @@ def require_auth(f):
def decorated(*args, **kwargs):
if not _AUTH_PASS:
return f(*args, **kwargs)
auth = request.authorization
if not auth or auth.username != _AUTH_USER or auth.password != _AUTH_PASS:
return Response(
'Unauthorized', 401,
{'WWW-Authenticate': 'Basic realm="micronas"'},
)
if not session.get('logged_in'):
if request.path.startswith('/api/'):
return Response('Unauthorized', 401)
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
def validate_path(path_str):
"""Resolve path and ensure it falls under /srv."""
try:
p = Path(path_str).resolve()
except Exception:
@@ -52,11 +55,49 @@ def validate_path(path_str):
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_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():
result = subprocess.run(['testparm', '-s'], capture_output=True, text=True)
shares = []
@@ -68,7 +109,7 @@ def parse_smb():
if m:
if current is not None and current['name'].lower() not in _SMB_SKIP:
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:
key, _, val = line.partition('=')
key = key.strip().lower().replace(' ', '_')
@@ -77,6 +118,8 @@ def parse_smb():
current['path'] = val
elif key == 'valid_users':
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:
shares.append(current)
@@ -99,22 +142,52 @@ def add_smb_share(name, path, valid_users):
# ===== NFS =====
def get_nfs_shares():
shares = []
def _parse_exports():
"""Parse /etc/exports preserving blanks, comments, and disabled (commented-out) exports."""
entries = []
try:
with open('/etc/exports', 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
for raw in f:
stripped = raw.strip()
if not stripped:
entries.append({'kind': 'blank', 'raw': raw})
continue
parts = line.split()
shares.append({
'path': parts[0],
'clients': ' '.join(parts[1:]) if len(parts) > 1 else '*',
})
if stripped.startswith('#'):
inner = stripped[1:].strip()
if inner.startswith('/'):
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:
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):
@@ -125,7 +198,30 @@ def add_nfs_share(path, clients, ro=False):
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('/')
@require_auth
@@ -139,6 +235,8 @@ def get_all_shares():
return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()})
# ===== SMB CRUD =====
@app.route('/api/share/smb', methods=['POST'])
@require_auth
def create_smb():
@@ -149,22 +247,85 @@ def create_smb():
if not VALID_NAME.match(name):
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:
clean_users = _validate_users(valid_users)
safe_path = validate_path(path)
except ValueError as e:
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'})
@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'])
@require_auth
def create_nfs():
@@ -184,6 +345,78 @@ def create_nfs():
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'])
@require_auth
def set_quota():
@@ -210,13 +443,9 @@ def set_quota():
except ValueError as e:
return jsonify({'error': str(e)}), 400
# setquota takes kilobytes; soft = requested, hard = +1 GB buffer
soft_kb = str(quota_gb * 1024 * 1024)
hard_kb = str((quota_gb + 1) * 1024 * 1024)
subprocess.run(
['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount],
check=True,
)
subprocess.run(['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount], check=True)
return jsonify({'status': 'ok'})
@@ -238,7 +467,7 @@ def _list_users():
parts = line.strip().split(':')
if len(parts) < 4:
continue
name, _, uid = parts[0], parts[1], parts[2]
name, uid = parts[0], parts[2]
uid = int(uid)
if uid < 1000 or uid == 65534:
continue
@@ -353,18 +582,14 @@ def _shares_with_usage():
result = []
for s in parse_smb():
result.append({
'type': 'smb',
'name': s['name'],
'path': s['path'],
'access': s['valid_users'],
'type': 'smb', 'name': s['name'], 'path': s['path'],
'access': s['valid_users'], 'enabled': s['enabled'],
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
})
for s in get_nfs_shares():
result.append({
'type': 'nfs',
'name': None,
'path': s['path'],
'access': s['clients'],
'type': 'nfs', 'name': None, 'path': s['path'],
'access': s['clients'], 'enabled': s['enabled'],
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
})
return result
@@ -388,9 +613,7 @@ def _quota_usage():
if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]):
continue
try:
used_kb = int(parts[2])
soft_kb = int(parts[3])
hard_kb = int(parts[4])
used_kb, soft_kb, hard_kb = int(parts[2]), int(parts[3]), int(parts[4])
except (ValueError, IndexError):
continue
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');
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 {
sb.innerHTML = shares.smb.map(s => `
<tr>
sb.innerHTML = shares.smb.map(s => {
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><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
<td>${statusDot}</td>
<td>
<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)})">
Quota
</button>
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
onclick="openEditModal('smb',${JSON.stringify(s)})">Edit</button>
<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)})">Quota</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>
</tr>`).join('');
</tr>`;
}).join('');
}
const nb = document.getElementById('nfsTableBody');
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 {
nb.innerHTML = shares.nfs.map(s => `
<tr>
nb.innerHTML = shares.nfs.map(s => {
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 style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
<td>${statusDot}</td>
<td>
<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)">
Quota
</button>
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
onclick="openEditModal('nfs',${JSON.stringify(s)})">Edit</button>
<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)">Quota</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>
</tr>`).join('');
</tr>`;
}).join('');
}
}
@@ -58,8 +72,7 @@ async function addSMBShare() {
document.getElementById('smbName').value = '';
document.getElementById('smbPath').value = '';
document.getElementById('smbUsers').value = '';
loadShares();
loadDashboard();
loadShares(); loadDashboard();
}
async function addNFSShare() {
@@ -79,6 +92,127 @@ async function addNFSShare() {
document.getElementById('nfsPath').value = '';
document.getElementById('nfsClients').value = '';
document.getElementById('nfsReadOnly').checked = false;
loadShares();
loadDashboard();
loadShares(); 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>
<!-- 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 -->
<div class="topbar d-flex justify-content-between align-items-center">
<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 class="container-lg py-4">
@@ -147,7 +167,7 @@
<div class="fw-semibold mb-2">Shares</div>
<div class="table-responsive">
<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">
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
</tbody>
@@ -172,7 +192,7 @@
<div class="fw-semibold mb-2">Exports</div>
<div class="table-responsive">
<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">
<tr><td colspan="3" style="color:var(--muted)">Loading…</td></tr>
</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>