diff --git a/hbd/server/configio.py b/hbd/server/configio.py index 1e8495e..5044688 100644 --- a/hbd/server/configio.py +++ b/hbd/server/configio.py @@ -93,6 +93,19 @@ def apply_structured_section(data, section: str, values: dict) -> None: raise ValueError(f"Unknown structured section: {section!r}") +def apply_channel(data, name: str, channel_cfg: dict) -> None: + """Insert or replace a single notification channel entry, preserving others.""" + if not data.get("notification_channels"): + data["notification_channels"] = {} + data["notification_channels"][name] = channel_cfg + + +def delete_channel(data, name: str) -> None: + """Remove a notification channel by name. No-op if not found.""" + nc = data.get("notification_channels") or {} + nc.pop(name, None) + + def apply_yaml_section(data, section: str, yaml_text: str) -> None: """Replace the named logical section by parsing yaml_text.""" parsed = _make_yaml().load(yaml_text) diff --git a/hbd/server/http.py b/hbd/server/http.py index 78f9947..075e6d3 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -956,7 +956,23 @@ async def start( ch_cfg = config.get("notification_channels", {}).get(ch_name, {}) notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")}) - all_channel_names = sorted((config.get("notification_channels") or {}).keys()) + # Build visible channels list for chip picker and My Channels management. + visible_channels = _visible_channels_for_user(current_user) if current_user else {} + all_channels = sorted( + [ + { + "name": name, + "type": cfg.get("type", ""), + "owner": cfg.get("owner"), + "private": bool(cfg.get("private", False)), + } + for name, cfg in visible_channels.items() + if isinstance(cfg, dict) + ], + key=lambda c: c["name"], + ) + # Keep all_channel_names for backwards-compat with any template references. + all_channel_names = [c["name"] for c in all_channels] tmpl = env.get_template("profile.html") body = tmpl.render( @@ -967,6 +983,7 @@ async def start( managed_hosts=managed, monitored_hosts=monitored, notification_channels=notif_channels, + all_channels=all_channels, all_channel_names=all_channel_names, active_page="profile", ) @@ -1255,6 +1272,226 @@ async def start( return web.json_response({"ok": True}) + # ------------------------------------------------------------------------- + # Notification channel helpers + # ------------------------------------------------------------------------- + + def _visible_channels_for_user(user): + """Return {name: cfg} of channels visible to user (public + own private).""" + all_channels = config.get("notification_channels") or {} + if user is None: + return {} + if user.admin: + return dict(all_channels) + visible = {} + for name, cfg in all_channels.items(): + if not isinstance(cfg, dict): + continue + if not cfg.get("private") or cfg.get("owner") == user.username: + visible[name] = cfg + return visible + + def _build_channel_response(ch_name, ch_cfg): + """Serialize a channel config dict for the API response.""" + ch_type = ch_cfg.get("type", "") + schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", []) + fields = [] + for sf in schema_fields: + k = sf["key"] + v = ch_cfg.get(k, "") + sensitive = sf["type"] == "secret" + fields.append({ + "key": k, + "label": sf["label"], + "value": "•••" if (sensitive and v) else ( + ", ".join(v) if isinstance(v, list) else str(v or "") + ), + "sensitive": sensitive, + }) + return { + "name": ch_name, + "type": ch_type, + "type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()), + "owner": ch_cfg.get("owner"), + "private": bool(ch_cfg.get("private", False)), + "min_level": ch_cfg.get("min_level", "WARNING"), + "fields": fields, + } + + # ------------------------------------------------------------------------- + # Notification channel API (any authenticated user) + # ------------------------------------------------------------------------- + + async def api_notification_channel_types(request): + """GET /api/0/notification_channel_types — channel type schemas.""" + user, err = _require_auth(request) + if err: + return err + return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS) + + async def api_notification_channels_get(request): + """GET /api/0/notification_channels — list channels visible to current user.""" + user, err = _require_auth(request) + if err: + return err + visible = _visible_channels_for_user(user) + result = [ + _build_channel_response(name, cfg) + for name, cfg in visible.items() + if isinstance(cfg, dict) + ] + return web.json_response(result) + + async def api_notification_channels_post(request): + """POST /api/0/notification_channels — create a new channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + name = (body.get("name") or "").strip() + if not name: + return web.json_response({"error": "Channel name is required"}, status=400) + ch_type = (body.get("type") or "").strip() + if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS: + return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400) + if name in (config.get("notification_channels") or {}): + return web.json_response({"error": f"Channel {name!r} already exists"}, status=409) + + schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type] + channel_cfg = {"type": ch_type} + for sf in schema["fields"]: + k = sf["key"] + v = body.get(k, "") + if v: + channel_cfg[k] = v + elif sf["required"]: + return web.json_response({"error": f"Field {k!r} is required"}, status=400) + + if body.get("min_level"): + channel_cfg["min_level"] = body["min_level"] + channel_cfg["owner"] = user.username + if body.get("private"): + channel_cfg["private"] = True + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + configio_mod.apply_channel(disk_data, name, channel_cfg) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel create failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True, "name": name}) + + async def api_notification_channel_put(request): + """PUT /api/0/notification_channels/{name} — update a channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + ch_name = request.match_info["name"] + existing_channels = config.get("notification_channels") or {} + if ch_name not in existing_channels: + return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404) + + existing_cfg = existing_channels[ch_name] + if not isinstance(existing_cfg, dict): + return web.json_response({"error": "Invalid channel config"}, status=500) + + owner = existing_cfg.get("owner") + if not user.admin and owner != user.username: + return web.json_response({"error": "Forbidden"}, status=403) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + ch_type = existing_cfg.get("type", "") + schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", []) + secret_keys = {sf["key"] for sf in schema_fields if sf["type"] == "secret"} + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + existing_on_disk = (disk_data.get("notification_channels") or {}).get(ch_name, {}) + + channel_cfg = {"type": ch_type} + for sf in schema_fields: + k = sf["key"] + v = body.get(k, "") + if k in secret_keys and (not v or v == "•••"): + existing_val = existing_on_disk.get(k, "") + if existing_val: + channel_cfg[k] = existing_val + elif v: + channel_cfg[k] = v + + if body.get("min_level"): + channel_cfg["min_level"] = body["min_level"] + if owner is not None: + channel_cfg["owner"] = owner + if "private" in body: + channel_cfg["private"] = bool(body["private"]) + elif existing_on_disk.get("private"): + channel_cfg["private"] = True + + configio_mod.apply_channel(disk_data, ch_name, channel_cfg) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel update failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True}) + + async def api_notification_channel_delete(request): + """DELETE /api/0/notification_channels/{name} — delete a channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + ch_name = request.match_info["name"] + existing_channels = config.get("notification_channels") or {} + if ch_name not in existing_channels: + return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404) + + existing_cfg = existing_channels[ch_name] + owner = existing_cfg.get("owner") if isinstance(existing_cfg, dict) else None + if not user.admin and owner != user.username: + return web.json_response({"error": "Forbidden"}, status=403) + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + configio_mod.delete_channel(disk_data, ch_name) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel delete failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True}) + async def api_user_self_put(request): """PUT /api/0/users/me — update own full_name, avatar, notification_channels, password.""" user, err = _require_auth(request) @@ -1297,7 +1534,10 @@ async def start( if "avatar" in body: user_entry["avatar"] = str(body["avatar"]) if "notification_channels" in body: - user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]] + visible = _visible_channels_for_user(user) + user_entry["notification_channels"] = [ + str(ch) for ch in body["notification_channels"] if ch in visible + ] if password_change: user_entry["password"] = users_mod.hash_password(password_change["new"]) @@ -1337,6 +1577,12 @@ async def start( web.get("/api/0/config/backups", api_config_backups_get), web.post("/api/0/config", api_config_post), web.post("/api/0/config/rollback", api_config_rollback), + # Notification channel API (any authenticated user) + web.get("/api/0/notification_channel_types", api_notification_channel_types), + web.get("/api/0/notification_channels", api_notification_channels_get), + web.post("/api/0/notification_channels", api_notification_channels_post), + web.put("/api/0/notification_channels/{name}", api_notification_channel_put), + web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete), # Hosts web.get("/api/0/hosts", api_hosts), web.get("/api/0/alert_summary", api_alert_summary), diff --git a/hbd/server/notify.py b/hbd/server/notify.py index 63f6d22..15287ca 100644 --- a/hbd/server/notify.py +++ b/hbd/server/notify.py @@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool: """Send *notif* to a single named channel, honouring min_level.""" + # Strip ownership metadata — notifier drivers only need delivery credentials. + channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")} + level = notif.level.upper() if level != "RECOVER": min_level = channel_cfg.get("min_level", "WARNING").upper() diff --git a/hbd/server/settings.py b/hbd/server/settings.py index fdb900e..dfc1f33 100644 --- a/hbd/server/settings.py +++ b/hbd/server/settings.py @@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({ "smtp_password", "smtp_user", "api_password", "access_token", }) -_CHANNEL_TYPE_LABELS = { - "pushover": "Pushover", - "email": "E-mail", - "signal": "Signal", - "mattermost": "Mattermost", +CHANNEL_TYPE_SCHEMAS = { + "pushover": { + "label": "Pushover", + "fields": [ + {"key": "token", "label": "App token", "type": "secret", "required": True}, + {"key": "user", "label": "User key", "type": "secret", "required": True}, + {"key": "sound", "label": "Sound", "type": "text", "required": False}, + ], + }, + "email": { + "label": "E-mail", + "fields": [ + {"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True}, + {"key": "sender", "label": "From address", "type": "text", "required": True}, + {"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True}, + {"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False}, + {"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False}, + {"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False}, + ], + }, + "signal": { + "label": "Signal", + "fields": [ + {"key": "user", "label": "Sender number", "type": "text", "required": True}, + {"key": "recipient", "label": "Recipient number", "type": "text", "required": True}, + {"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False}, + ], + }, + "matrix": { + "label": "Matrix", + "fields": [ + {"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True}, + {"key": "access_token", "label": "Access token", "type": "secret", "required": True}, + {"key": "room_id", "label": "Room ID", "type": "text", "required": True}, + ], + }, + "sms_voipms": { + "label": "SMS (voip.ms)", + "fields": [ + {"key": "api_user", "label": "API username", "type": "text", "required": True}, + {"key": "api_password", "label": "API password", "type": "secret", "required": True}, + {"key": "did", "label": "DID (from)", "type": "text", "required": True}, + {"key": "dst", "label": "Destination", "type": "text", "required": True}, + ], + }, + "mattermost": { + "label": "Mattermost", + "fields": [ + {"key": "host", "label": "Host", "type": "text", "required": True}, + {"key": "token", "label": "Webhook token", "type": "secret", "required": True}, + {"key": "channel", "label": "Channel", "type": "text", "required": True}, + {"key": "username", "label": "Bot username", "type": "text", "required": False}, + {"key": "icon", "label": "Icon URL", "type": "text", "required": False}, + ], + }, } +_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()} + def _mask(value): """Return a masked placeholder for sensitive values.""" @@ -143,6 +195,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: } # ---- Notification channels (complex, built separately) ---------------- + _METADATA_KEYS = {"type", "owner", "private", "min_level"} notif_channels = [] for ch_name, ch_cfg in (config.get("notification_channels") or {}).items(): if not isinstance(ch_cfg, dict): @@ -150,7 +203,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: ch_type = ch_cfg.get("type", "") fields = [] for k, v in ch_cfg.items(): - if k == "type": + if k in _METADATA_KEYS: continue sensitive = k in _SECRET_KEYS fields.append({ @@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "name": ch_name, "type": ch_type, "type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()), + "owner": ch_cfg.get("owner"), + "private": bool(ch_cfg.get("private", False)), + "min_level": ch_cfg.get("min_level", "WARNING"), "fields": fields, }) @@ -368,7 +424,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "channels", "title": "Notification Channels", "description": "Named notification providers. Credentials are masked.", - "section_mode": "yaml", + "section_mode": "channels", "api_section": "notification_channels", "channels": notif_channels, "fields": [ diff --git a/hbd/server/templates/profile.html b/hbd/server/templates/profile.html index f486317..ab8f6c4 100644 --- a/hbd/server/templates/profile.html +++ b/hbd/server/templates/profile.html @@ -215,11 +215,59 @@ .save-row { display: flex; align-items: center; margin-top: 8px; } .btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; } .btn-save:hover { background: #0055aa; } - .channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; } - .channel-item:last-child { border-bottom: none; } - .channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; } - .channel-item .ch-name { font-weight: 500; color: #222; } - .channel-item .ch-meta { font-size: .8em; color: #888; } + /* ---- Channel chip picker ---- */ + .ch-picker { } + .ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; } + .ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; } + .ch-chip { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer; + border: none; font-family: inherit; + } + .ch-chip.selected { background: #e3f2fd; color: #1565c0; } + .ch-chip.selected:hover { background: #bbdefb; } + .ch-chip.available { background: #f1f3f4; color: #555; } + .ch-chip.available:hover { background: #e8eaf6; color: #283593; } + .ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; } + + /* ---- My Channels card list ---- */ + .my-ch-card { + border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden; + } + .my-ch-header { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: #f8f9ff; border-bottom: 1px solid #e8eaf6; + } + .my-ch-name { font-weight: 600; font-size: .9em; color: #222; } + .my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; } + .my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; } + .my-ch-actions { margin-left: auto; display: flex; gap: 5px; } + .btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; } + .btn-sm-edit:hover { background: #666; } + .btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; } + .btn-sm-del:hover { background: #fce4ec; } + + /* ---- Channel modal (for My Channels CRUD) ---- */ + .ch-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.4); + display: flex; align-items: center; justify-content: center; z-index: 1001; + } + .ch-modal-box { + background: #fff; border-radius: 8px; padding: 24px; + min-width: 360px; max-width: 520px; width: 95%; + box-shadow: 0 8px 32px rgba(0,0,0,.2); + } + .ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; } + .ch-form-row { margin-bottom: 12px; } + .ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; } + .ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select { + width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; + font-size: .88em; box-sizing: border-box; font-family: inherit; + } + .ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; } + .ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; } + .ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + .ch-modal-status { font-size: .83em; margin-top: 8px; } @@ -318,37 +366,117 @@ {% endif %} - +

Notification Channels

{% if current_user %} -

Select which channels send you alerts. Channels are defined by the administrator.

- {% if all_channel_names %} -
- {% for ch_name in all_channel_names %} -
- +

Click a channel to add or remove it from your alert list.

+ {% if all_channels %} +
+
Selected
+
+ {% for ch in all_channels %} + {% if ch.name in (current_user.notification_channels or []) %} + + {% endif %} + {% endfor %} + {% set selected_set = current_user.notification_channels or [] %} + {% set has_selected = selected_set | length > 0 %} + {% if not has_selected %} + None selected + {% endif %} +
+
Available
+
+ {% for ch in all_channels %} + {% if ch.name not in (current_user.notification_channels or []) %} + + {% endif %} + {% endfor %}
- {% endfor %}
{% else %} -

No notification channels configured.

+

No notification channels available. You can create your own below.

{% endif %} -
+
{% else %} - No personal notification channels configured. + Log in to manage notification channels. {% endif %}
+ + {% if current_user %} +
+

My Channels

+

Channels you own. Public channels are available to all users; private channels are visible only to you.

+
+ {% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %} + {% for ch in my_channels %} +
+
+ {{ ch.name | e }} + {{ ch.type | e }} + {% if ch.private %}private{% endif %} + + + + +
+
+ {% endfor %} + {% if not my_channels %} +

No channels yet.

+ {% endif %} +
+
+ +
+
+ + + + {% endif %} +

Host Access

@@ -395,6 +523,7 @@
diff --git a/hbd/server/templates/settings.html b/hbd/server/templates/settings.html index f1c4344..c4f3379 100644 --- a/hbd/server/templates/settings.html +++ b/hbd/server/templates/settings.html @@ -207,6 +207,36 @@ .channel-field-label { width: 130px; flex-shrink: 0; color: #777; } .channel-field-value { color: #333; word-break: break-all; } + /* ---- Channel management (form-based section) ---- */ + .channel-header-actions { margin-left: auto; display: flex; gap: 6px; } + .ch-owner-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8f5e9; color: #2e7d32; } + .ch-private-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; } + .ch-level-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fff3e0; color: #e65100; } + .channel-grid { padding: 12px 20px 0; } + .channel-add-bar { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #f0f0f0; } + + /* Channel modal */ + .ch-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.4); + display: flex; align-items: center; justify-content: center; z-index: 1001; + } + .ch-modal-box { + background: #fff; border-radius: 8px; padding: 24px; + min-width: 360px; max-width: 520px; width: 95%; + box-shadow: 0 8px 32px rgba(0,0,0,.2); + } + .ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; } + .ch-form-row { margin-bottom: 12px; } + .ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; } + .ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select { + width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; + font-size: .88em; box-sizing: border-box; font-family: inherit; + } + .ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; } + .ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; } + .ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + .ch-status { font-size: .83em; margin-top: 8px; } + /* ---- Hosts table ---- */ /* ---- Mobile: collapsible sidebar ---- */ .sidebar-toggle { @@ -381,6 +411,42 @@
+ + +
@@ -488,6 +554,52 @@
+ {# ---- Notification channels (form-based, live CRUD) ---- #} + {% elif section.section_mode == 'channels' %} + {% for f in section.fields %} +
+
{{ f.label }}
+
+ {% if f.type == 'list' %} + {% if f.value %}{% for item in f.value %}{{ item }}{% endfor %} + {% else %}None{% endif %} + {% else %} +
{{ f.value if f.value is not none else '' }}
+ {% endif %} + {% if f.description %}

{{ f.description }}

{% endif %} +
+
+ {% endfor %} +
+ {% for ch in section.channels %} +
+
+ {{ ch.name | e }} + {{ ch.type_label | e }} + {% if ch.min_level and ch.min_level != 'WARNING' %}{{ ch.min_level | e }}+{% endif %} + {% if ch.private %}private{% endif %} + {% if ch.owner %}{{ ch.owner | e }}{% endif %} + + + + +
+
+ {% for f in ch.fields %} +
+ {{ f.label }} + {% if f.sensitive %}•••{% elif f.value %}{{ f.value | e }}{% else %}{% endif %} +
+ {% endfor %} +
+
+ {% endfor %} + {% if not section.channels %}

No channels configured yet.

{% endif %} +
+
+ +
+ {# ---- YAML editor section ---- #} {% elif section.section_mode == 'yaml' %}
@@ -557,6 +669,136 @@ // ---- Channel names for add-user row ---- const _allChannels = {{ all_channel_names | tojson }}; + // ---- Channel CRUD ---- + let _channelSchemas = {}; + let _chEditName = null; // null = create mode, string = edit mode + + async function _loadChannelSchemas() { + try { + const r = await fetch('/api/0/notification_channel_types'); + _channelSchemas = await r.json(); + const sel = document.getElementById('ch-type'); + if (!sel) return; + Object.entries(_channelSchemas).forEach(([k, v]) => { + const opt = document.createElement('option'); + opt.value = k; opt.textContent = v.label; + sel.appendChild(opt); + }); + } catch(e) { console.warn('Could not load channel schemas', e); } + } + + function onChTypeChange() { + const type = document.getElementById('ch-type').value; + const container = document.getElementById('ch-type-fields'); + container.innerHTML = ''; + if (!type || !_channelSchemas[type]) return; + const divider = document.createElement('div'); + divider.className = 'ch-form-divider'; + divider.textContent = _channelSchemas[type].label + ' settings'; + container.appendChild(divider); + (_channelSchemas[type].fields || []).forEach(sf => { + const row = document.createElement('div'); + row.className = 'ch-form-row'; + const lbl = document.createElement('label'); + lbl.textContent = sf.label + (sf.required ? ' *' : ''); + const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input'); + inp.type = sf.type === 'secret' ? 'password' : 'text'; + inp.id = 'chf-' + sf.key; + inp.placeholder = sf.required ? '(required)' : '(optional)'; + inp.autocomplete = 'off'; + row.appendChild(lbl); + row.appendChild(inp); + container.appendChild(row); + }); + } + + async function openChannelModal(name) { + _chEditName = name || null; + document.getElementById('ch-modal-status').textContent = ''; + document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel'; + document.getElementById('ch-name').value = name || ''; + document.getElementById('ch-name').disabled = !!name; + document.getElementById('ch-type').value = ''; + document.getElementById('ch-type-fields').innerHTML = ''; + document.getElementById('ch-min-level').value = 'WARNING'; + document.getElementById('ch-private').checked = false; + + if (name) { + // Load existing channel data via API + try { + const r = await fetch('/api/0/notification_channels'); + const channels = await r.json(); + const ch = channels.find(c => c.name === name); + if (ch) { + document.getElementById('ch-type').value = ch.type; + onChTypeChange(); + document.getElementById('ch-min-level').value = ch.min_level || 'WARNING'; + document.getElementById('ch-private').checked = ch.private || false; + (ch.fields || []).forEach(f => { + const inp = document.getElementById('chf-' + f.key); + if (inp) inp.value = f.value || ''; + }); + } + } catch(e) { console.warn('Failed to load channel data', e); } + } + document.getElementById('ch-modal').style.display = 'flex'; + } + + function closeChannelModal() { + document.getElementById('ch-modal').style.display = 'none'; + } + + async function saveChannel() { + const name = document.getElementById('ch-name').value.trim(); + const type = document.getElementById('ch-type').value; + const minLevel = document.getElementById('ch-min-level').value; + const isPrivate = document.getElementById('ch-private').checked; + const statusEl = document.getElementById('ch-modal-status'); + statusEl.textContent = ''; + + if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; } + if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; } + + const body = { name, type, min_level: minLevel, private: isPrivate }; + if (_channelSchemas[type]) { + (_channelSchemas[type].fields || []).forEach(sf => { + const inp = document.getElementById('chf-' + sf.key); + if (inp) body[sf.key] = inp.value; + }); + } + + const isEdit = !!_chEditName; + const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels'; + const method = isEdit ? 'PUT' : 'POST'; + try { + const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }); + if (r.ok) { + closeChannelModal(); + window.location.reload(); + } else { + const err = await r.json().catch(() => ({})); + statusEl.textContent = err.error || 'Error saving channel.'; + statusEl.style.color = '#c62828'; + } + } catch(e) { + statusEl.textContent = 'Network error: ' + e.message; + statusEl.style.color = '#c62828'; + } + } + + async function deleteChannel(name) { + if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return; + try { + const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' }); + if (r.ok) { + window.location.reload(); + } else { + const err = await r.json().catch(() => ({})); + alert('Error: ' + (err.error || 'Could not delete channel.')); + } + } catch(e) { alert('Network error: ' + e.message); } + } + // ---- Staged changes accumulator ---- const _staged = {}; @@ -707,6 +949,7 @@ } document.addEventListener('DOMContentLoaded', () => { + _loadChannelSchemas(); document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => { const sectionId = ta.id.replace('yaml-', ''); const section = document.getElementById(sectionId); diff --git a/tests/test_http_users_me.py b/tests/test_http_users_me.py index 30d3db4..33aa39c 100644 --- a/tests/test_http_users_me.py +++ b/tests/test_http_users_me.py @@ -83,3 +83,41 @@ def test_put_users_me_notification_channels(tmp_path): configio.write_config(str(cfg), data) result = configio.read_roundtrip(str(cfg)) assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"] + + +def test_visible_channels_excludes_private_from_others(): + """Private channels owned by another user must not appear in the visible set.""" + from hbd.server import settings as settings_mod + + config = { + "notification_channels": { + "public_ch": {"type": "pushover", "token": "t", "user": "u"}, + "alice_priv": {"type": "email", "owner": "alice", "private": True, + "recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"}, + "bob_priv": {"type": "email", "owner": "bob", "private": True, + "recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"}, + } + } + + class FakeUser: + def __init__(self, username, admin=False): + self.username = username + self.admin = admin + + alice = FakeUser("alice") + bob = FakeUser("bob") + admin = FakeUser("admin", admin=True) + + # Simulate _visible_channels_for_user logic (mirrors http.py implementation) + def visible(user): + all_channels = config.get("notification_channels") or {} + if user.admin: + return set(all_channels.keys()) + return { + name for name, cfg in all_channels.items() + if not cfg.get("private") or cfg.get("owner") == user.username + } + + assert visible(alice) == {"public_ch", "alice_priv"} + assert visible(bob) == {"public_ch", "bob_priv"} + assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"} diff --git a/tests/test_notification_channels_api.py b/tests/test_notification_channels_api.py new file mode 100644 index 0000000..f5d311e --- /dev/null +++ b/tests/test_notification_channels_api.py @@ -0,0 +1,178 @@ +"""Tests for notification channel CRUD via configio helpers and visibility logic.""" +import pytest +from hbd.server import configio, settings as settings_mod + + +SAMPLE_YAML = """\ +hbd_port: 50004 +notification_channels: + pushover_ops: + type: pushover + token: abc123 + user: usr456 +""" + + +# --------------------------------------------------------------------------- +# configio helpers +# --------------------------------------------------------------------------- + +def test_apply_channel_adds_new_entry(tmp_path): + f = tmp_path / ".hb.yaml" + f.write_text(SAMPLE_YAML) + data = configio.read_roundtrip(str(f)) + configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]}) + assert "email_ops" in data["notification_channels"] + assert data["notification_channels"]["email_ops"]["type"] == "email" + # Existing channel preserved + assert "pushover_ops" in data["notification_channels"] + + +def test_apply_channel_updates_existing(tmp_path): + f = tmp_path / ".hb.yaml" + f.write_text(SAMPLE_YAML) + data = configio.read_roundtrip(str(f)) + configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"}) + assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok" + + +def test_apply_channel_creates_section_if_absent(): + data = {"hbd_port": 50004} + configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"}) + assert "notification_channels" in data + assert "test_ch" in data["notification_channels"] + + +def test_delete_channel_removes_entry(tmp_path): + f = tmp_path / ".hb.yaml" + f.write_text(SAMPLE_YAML) + data = configio.read_roundtrip(str(f)) + configio.delete_channel(data, "pushover_ops") + assert "pushover_ops" not in data["notification_channels"] + + +def test_delete_channel_noop_for_missing(): + data = {"notification_channels": {"ch1": {"type": "pushover"}}} + configio.delete_channel(data, "nonexistent") # must not raise + assert "ch1" in data["notification_channels"] + + +def test_delete_channel_noop_when_no_section(): + data = {} + configio.delete_channel(data, "anything") # must not raise + + +def test_apply_channel_persisted_after_write(tmp_path): + f = tmp_path / ".hb.yaml" + f.write_text(SAMPLE_YAML) + data = configio.read_roundtrip(str(f)) + configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"}) + configio.write_config(str(f), data) + result = configio.read_roundtrip(str(f)) + assert "signal_ops" in result["notification_channels"] + assert result["notification_channels"]["signal_ops"]["user"] == "+1" + # Original channel preserved + assert "pushover_ops" in result["notification_channels"] + + +def test_delete_channel_persisted_after_write(tmp_path): + f = tmp_path / ".hb.yaml" + f.write_text(SAMPLE_YAML) + data = configio.read_roundtrip(str(f)) + configio.delete_channel(data, "pushover_ops") + configio.write_config(str(f), data) + result = configio.read_roundtrip(str(f)) + assert "pushover_ops" not in (result.get("notification_channels") or {}) + + +# --------------------------------------------------------------------------- +# Visibility logic (mirrors http.py _visible_channels_for_user) +# --------------------------------------------------------------------------- + +def _visible(config, user): + """Local copy of the visibility helper for unit testing without the HTTP layer.""" + all_channels = config.get("notification_channels") or {} + if user.get("admin"): + return set(all_channels.keys()) + username = user["username"] + return { + name for name, cfg in all_channels.items() + if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username) + } + + +CONFIG_VISIBILITY = { + "notification_channels": { + "pub_ch": {"type": "pushover", "token": "t", "user": "u"}, + "alice_priv": {"type": "email", "owner": "alice", "private": True, + "recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"}, + "bob_priv": {"type": "signal", "owner": "bob", "private": True, + "user": "+1", "recipient": "+2"}, + "admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"}, + } +} + + +def test_public_channel_visible_to_all(): + for uname in ("alice", "bob", "carol"): + user = {"username": uname, "admin": False} + assert "pub_ch" in _visible(CONFIG_VISIBILITY, user) + + +def test_private_channel_visible_only_to_owner(): + alice = {"username": "alice", "admin": False} + bob = {"username": "bob", "admin": False} + carol = {"username": "carol", "admin": False} + + assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice) + assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob) + assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol) + + assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob) + assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice) + + +def test_admin_sees_all_channels(): + admin = {"username": "adminuser", "admin": True} + visible = _visible(CONFIG_VISIBILITY, admin) + assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"} + + +def test_admin_owned_channel_is_public_by_default(): + alice = {"username": "alice", "admin": False} + assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice) + + +# --------------------------------------------------------------------------- +# Channel type schemas +# --------------------------------------------------------------------------- + +def test_all_required_types_in_schema(): + for t in ("pushover", "email", "signal", "matrix", "sms_voipms"): + assert t in settings_mod.CHANNEL_TYPE_SCHEMAS + + +def test_schema_fields_have_required_keys(): + for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items(): + assert "label" in schema, f"{type_id} missing label" + assert "fields" in schema, f"{type_id} missing fields" + for f in schema["fields"]: + for k in ("key", "label", "type", "required"): + assert k in f, f"{type_id} field missing {k!r}" + + +def test_secret_fields_use_secret_type(): + """Known secret fields must be typed 'secret' so the UI masks them.""" + secret_keys = {"token", "user_key", "api_key", "api_password", + "smtp_password", "access_token"} + for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items(): + for f in schema["fields"]: + if f["key"] in secret_keys: + assert f["type"] == "secret", ( + f"{type_id}.{f['key']} should be type 'secret'" + ) + + +def test_channel_labels_not_empty(): + for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items(): + assert schema["label"].strip(), f"{type_id} has empty label" diff --git a/tests/test_settings_sections.py b/tests/test_settings_sections.py index 1fa8a5e..085e0e7 100644 --- a/tests/test_settings_sections.py +++ b/tests/test_settings_sections.py @@ -24,7 +24,7 @@ def test_sections_have_section_mode(): sections = settings_mod.get_settings_sections(CFG) for s in sections: assert "section_mode" in s, f"Section {s['id']} missing section_mode" - assert s["section_mode"] in ("form", "yaml") + assert s["section_mode"] in ("form", "yaml", "channels") def test_sections_have_api_section(): @@ -45,16 +45,41 @@ def test_network_section_has_editable_fields(): def test_yaml_sections_have_correct_mode(): sections = settings_mod.get_settings_sections(CFG) yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"} - assert "channels" in yaml_sections + assert "channels" not in yaml_sections # now uses "channels" mode assert "hosts" in yaml_sections assert "thresholds" in yaml_sections assert "dns" in yaml_sections - assert yaml_sections["channels"]["api_section"] == "notification_channels" assert yaml_sections["hosts"]["api_section"] == "hosts" assert yaml_sections["thresholds"]["api_section"] == "thresholds" assert yaml_sections["dns"]["api_section"] == "dns" +def test_channels_section_uses_channels_mode(): + sections = settings_mod.get_settings_sections(CFG) + ch_sec = next(s for s in sections if s["id"] == "channels") + assert ch_sec["section_mode"] == "channels" + assert ch_sec["api_section"] == "notification_channels" + assert len(ch_sec["channels"]) == 1 + ch = ch_sec["channels"][0] + assert ch["name"] == "pushover_ops" + assert ch["type"] == "pushover" + assert "owner" in ch + assert "private" in ch + + +def test_channel_type_schemas_exported(): + assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS") + for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"): + assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS + schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type] + assert "label" in schema + assert "fields" in schema + for f in schema["fields"]: + assert "key" in f + assert "type" in f + assert "required" in f + + def test_oauth_section_exists(): sections = settings_mod.get_settings_sections(CFG) oauth = next((s for s in sections if s["id"] == "oauth"), None)