diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index ea480f5..783cf3e 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -25,10 +25,20 @@ DASHB_INTERVAL_SECS = 60 QUEUE_MAX_LINES = 50 +_config_cache = None +_config_mtime = None + def load_config(): + global _config_cache, _config_mtime try: + mtime = os.path.getmtime(CONFIG_FILE) + if _config_cache is not None and mtime == _config_mtime: + return copy.deepcopy(_config_cache) with open(CONFIG_FILE) as f: - return json.load(f) + data = json.load(f) + _config_cache = data + _config_mtime = mtime + return copy.deepcopy(data) except Exception: return {} diff --git a/docker/routlin-dash/app/pages/captiveportal/action.py b/docker/routlin-dash/app/pages/captiveportal/action.py index 9271d79..fc486a1 100644 --- a/docker/routlin-dash/app/pages/captiveportal/action.py +++ b/docker/routlin-dash/app/pages/captiveportal/action.py @@ -35,19 +35,38 @@ def options_save(): return redirect(f'/{_PAGE}') -@bp.route('/action/captiveportal/splash_save', methods=['POST']) +@bp.route('/action/captiveportal/portal_save', methods=['POST']) @auth.require_level('administrator') -def splash_save(): - cfg = config_utils.load_config() - before = copy.deepcopy(cfg.get('captive_portal', {})) +def portal_save(): + cfg = config_utils.load_config() + vlan_name = sanitize.name(request.form.get('vlan_name', '')) - splash_text = sanitize.description(request.form.get('splash_text', '')) - terms = [t.strip() for t in request.form.getlist('terms') if t.strip()] + vlan = next((v for v in cfg.get('vlans', []) if v['name'] == vlan_name), None) + if not vlan or vlan.get('restricted_vlan') != 'c': + flash('Captive portal VLAN not found.', 'error') + return redirect(f'/{_PAGE}') - after = {**before, 'splash_text': splash_text, 'terms': terms} - cfg.setdefault('captive_portal', {}).update(after) + before = { + 'portal_splash_title': vlan.get('portal_splash_title', ''), + 'portal_splash_text': vlan.get('portal_splash_text', ''), + 'portal_terms': vlan.get('portal_terms', []), + } + + splash_title = sanitize.description(request.form.get('portal_splash_title', '')) + splash_text = sanitize.description(request.form.get('portal_splash_text', '')) + terms = [t.strip() for t in request.form.getlist('portal_terms') if t.strip()] + + vlan['portal_splash_title'] = splash_title + vlan['portal_splash_text'] = splash_text + vlan['portal_terms'] = terms + + after = { + 'portal_splash_title': splash_title, + 'portal_splash_text': splash_text, + 'portal_terms': terms, + } changes = config_utils.diff_fields(before, after) flash(config_utils.record_group( - cfg, 'captive_portal', 'setting', 'captive_portal', changes, 'core apply' + cfg, 'vlans', 'portal', vlan_name, changes, 'core apply' ), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/captiveportal/content.json b/docker/routlin-dash/app/pages/captiveportal/content.json index 10958f5..1eb86b7 100644 --- a/docker/routlin-dash/app/pages/captiveportal/content.json +++ b/docker/routlin-dash/app/pages/captiveportal/content.json @@ -64,32 +64,81 @@ } ] }, + { + "type": "table", + "datasource": "captive_portals", + "empty_message": "No captive portal VLANs configured. Set Restricted VLAN = Captive Portal on the Network Layout page.", + "columns": [ + { + "label": "VLAN", + "field": "vlan_name", + "class": "col-mono" + }, + { + "label": "Title", + "field": "portal_splash_title" + }, + { + "label": "Splash Text", + "field": "portal_splash_text" + }, + { + "label": "Terms", + "field": "portal_terms_display", + "class": "col-narrow" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "method": "js_edit", + "target": "portal-edit-form", + "text": "Edit", + "class": "btn-ghost btn-sm" + } + ] + }, { "type": "card", - "label": "Splash Screen", + "id": "portal-edit-form", + "label": "Edit Portal", "client_requirement": "client_is_administrator+", "items": [ { "type": "form", - "action": "/action/captiveportal/splash_save", + "action": "/action/captiveportal/portal_save", "method": "post", "items": [ + { + "type": "hidden", + "name": "vlan_name", + "value": "" + }, { "type": "field", - "label": "Welcome Text", - "name": "splash_text", + "label": "Title", + "name": "portal_splash_title", "input_type": "text", - "value": "%CAPTIVE_SPLASH_TEXT%", - "hint": "Welcome message shown at the top of the login page." + "hint": "Heading shown at the top of the login page." + }, + { + "type": "field", + "label": "Splash Text", + "name": "portal_splash_text", + "input_type": "text", + "hint": "Welcome message shown below the title." + }, + { + "type": "hr" }, { "type": "editable_list", "label": "Terms", - "name": "terms", - "items": "%CAPTIVE_TERMS%", + "name": "portal_terms", + "items": "[]", "add_label": "Add Term", "item_placeholder": "e.g. I agree to the acceptable use policy.", - "hint": "Each term renders as a required checkbox the user must tick before submitting credentials." + "hint": "Each term renders as a required checkbox the user must tick before submitting credentials. Leave empty for no terms." }, { "type": "button_row", diff --git a/docker/routlin-dash/app/pages/captiveportal/view.py b/docker/routlin-dash/app/pages/captiveportal/view.py index 019f2f8..5173384 100644 --- a/docker/routlin-dash/app/pages/captiveportal/view.py +++ b/docker/routlin-dash/app/pages/captiveportal/view.py @@ -20,7 +20,23 @@ def collect_tokens(cfg): tokens['CAPTIVE_HTTP_PORT'] = str(cp.get('http_port', 8081)) tokens['CAPTIVE_HTTPS_DOMAIN'] = cp.get('https_domain', '') - tokens['CAPTIVE_SPLASH_TEXT'] = cp.get('splash_text', '') - tokens['CAPTIVE_TERMS'] = json.dumps(cp.get('terms', [])) + + display_rows = [] + for vlan in captive_vlans: + terms = vlan.get('portal_terms', []) + n = len(terms) + display_rows.append({ + 'vlan_name': vlan['name'], + 'portal_splash_title': vlan.get('portal_splash_title', ''), + 'portal_splash_text': vlan.get('portal_splash_text', ''), + 'portal_terms': terms, + 'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--', + }) + + content = factory.load_json(f'{factory.PAGES_DIR}/captiveportal/content.json') + for table_item in factory.iter_table_items(content.get('items', [])): + ds = table_item.get('datasource', '') + data = display_rows if ds == 'captive_portals' else [] + tokens[factory.table_token_key(ds)] = factory.build_table(table_item, tokens, data) return tokens