feat: full channels.json UI + http_allowlist settings
Notifications page:
- NC Talk section expanded: url, bot_secret, notification_room,
nc_username, nc_app_password — all fields from channels.json now editable
- Per-channel sections use <details>/<summary> collapsibles; auto-open
when values are present
- Secrets use type=password with "leave blank to keep" semantics
- Google Chat outbound webhook in its own collapsible section
Account settings:
- HTTP POST Allowlist section added (same textarea pattern as email allowlist)
- POST /settings/http-allowlist route saves home/{user}/http_allowlist.json
- Example placeholder shows ha.dgrzone.com and n8n patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,14 +56,25 @@ def _preferred_persona(request: Request, username: str) -> str:
|
||||
|
||||
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "notifications.html").read_text()
|
||||
channels = get_user_channels(username)
|
||||
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
||||
notify_email = _html.escape(channels.get("notification_email", "") or "")
|
||||
nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "")
|
||||
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
|
||||
channels = get_user_channels(username)
|
||||
nct = channels.get("nextcloud") or {}
|
||||
|
||||
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
||||
notify_email = _html.escape(channels.get("notification_email", "") or "")
|
||||
nc_url = _html.escape(nct.get("url", "") or "")
|
||||
nc_bot_secret = _html.escape(nct.get("bot_secret", "") or "")
|
||||
nc_room = _html.escape(nct.get("notification_room", "") or "")
|
||||
nc_username = _html.escape(nct.get("nc_username", "") or "")
|
||||
nc_app_password = _html.escape(nct.get("nc_app_password", "") or "")
|
||||
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
|
||||
|
||||
html = html.replace("{{ notify_channel }}", notify_ch)
|
||||
html = html.replace("{{ notify_email_override }}", notify_email)
|
||||
html = html.replace("{{ nc_url }}", nc_url)
|
||||
html = html.replace("{{ nc_bot_secret }}", nc_bot_secret)
|
||||
html = html.replace("{{ nc_notify_room }}", nc_room)
|
||||
html = html.replace("{{ nc_username }}", nc_username)
|
||||
html = html.replace("{{ nc_app_password }}", nc_app_password)
|
||||
html = html.replace("{{ gc_webhook }}", gc_webhook)
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
@@ -94,6 +105,14 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
|
||||
allowlist_text = ""
|
||||
html = html.replace("{{ email_allowlist }}", allowlist_text)
|
||||
|
||||
http_al_path = app_settings.home_root() / username / "http_allowlist.json"
|
||||
try:
|
||||
http_prefixes = json.loads(http_al_path.read_text())
|
||||
http_allowlist_text = _html.escape("\n".join(str(p) for p in http_prefixes if str(p).strip()))
|
||||
except Exception:
|
||||
http_allowlist_text = ""
|
||||
html = html.replace("{{ http_allowlist }}", http_allowlist_text)
|
||||
|
||||
# Tool permission policy
|
||||
policy = get_tool_policy(username)
|
||||
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
|
||||
@@ -284,7 +303,11 @@ async def save_notifications(
|
||||
request: Request,
|
||||
notification_channel: str = Form(""),
|
||||
notification_email: str = Form(""),
|
||||
nc_url: str = Form(""),
|
||||
nc_bot_secret: str = Form(""),
|
||||
nc_notification_room: str = Form(""),
|
||||
nc_username: str = Form(""),
|
||||
nc_app_password: str = Form(""),
|
||||
gc_outbound_webhook: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
@@ -313,10 +336,20 @@ async def save_notifications(
|
||||
else:
|
||||
channels.pop("notification_email", None)
|
||||
|
||||
# NC Talk notification room — nested under "nextcloud"
|
||||
# Nextcloud Talk — full config nested under "nextcloud"
|
||||
if "nextcloud" not in channels:
|
||||
channels["nextcloud"] = {}
|
||||
channels["nextcloud"]["notification_room"] = nc_notification_room.strip()
|
||||
nct = channels["nextcloud"]
|
||||
if nc_url.strip():
|
||||
nct["url"] = nc_url.strip().rstrip("/")
|
||||
# Only overwrite secrets if a new value was provided (blank = keep existing)
|
||||
if nc_bot_secret.strip():
|
||||
nct["bot_secret"] = nc_bot_secret.strip()
|
||||
nct["notification_room"] = nc_notification_room.strip()
|
||||
if nc_username.strip():
|
||||
nct["nc_username"] = nc_username.strip()
|
||||
if nc_app_password.strip():
|
||||
nct["nc_app_password"] = nc_app_password.strip()
|
||||
|
||||
# Google Chat outbound webhook — nested under "google_chat"
|
||||
if "google_chat" not in channels:
|
||||
@@ -365,3 +398,21 @@ async def save_email_allowlist(
|
||||
path.write_text(json.dumps(lines, indent=2))
|
||||
logger.info("email allowlist updated for %s (%d patterns)", username, len(lines))
|
||||
return HTMLResponse(_settings_page(username, personas, back_persona, success=f"Email allowlist saved ({len(lines)} pattern{'s' if len(lines) != 1 else ''})."))
|
||||
|
||||
|
||||
@router.post("/settings/http-allowlist", include_in_schema=False)
|
||||
async def save_http_allowlist(
|
||||
request: Request,
|
||||
prefixes: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
personas = list_user_personas(username)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
lines = [ln.strip() for ln in prefixes.splitlines() if ln.strip()]
|
||||
path = app_settings.home_root() / username / "http_allowlist.json"
|
||||
path.write_text(json.dumps(lines, indent=2))
|
||||
logger.info("http allowlist updated for %s (%d prefixes)", username, len(lines))
|
||||
return HTMLResponse(_settings_page(username, personas, back_persona, success=f"HTTP allowlist saved ({len(lines)} prefix{'es' if len(lines) != 1 else ''})."))
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
@@ -109,6 +109,7 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus { border-color: #7c3aed; }
|
||||
input[type="password"] { font-family: monospace; letter-spacing: 0.05em; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
@@ -157,7 +158,49 @@
|
||||
}
|
||||
.test-result.ok { background: rgba(74, 222, 128, 0.1); color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.25); }
|
||||
.test-result.err { background: rgba(248, 113, 113, 0.1); color: #f87171; border: 1px solid rgba(248, 113, 113, 0.25); }
|
||||
|
||||
.hint { font-size: 0.78rem; color: var(--pg-dim); margin-top: 0.35rem; line-height: 1.5; }
|
||||
|
||||
/* Channel config blocks */
|
||||
details.channel-block {
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
details.channel-block summary {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--pg-muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
user-select: none;
|
||||
background: var(--pg-bg);
|
||||
}
|
||||
details.channel-block summary::-webkit-details-marker { display: none; }
|
||||
details.channel-block summary::before {
|
||||
content: '▶';
|
||||
font-size: 0.65rem;
|
||||
color: var(--pg-dimmer);
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
details.channel-block[open] summary::before { transform: rotate(90deg); }
|
||||
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
|
||||
.channel-block-body {
|
||||
padding: 1rem 1rem 0.25rem;
|
||||
}
|
||||
.channel-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--pg-dimmer);
|
||||
margin-top: -0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -179,12 +222,13 @@
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- Channel config -->
|
||||
<div class="section">
|
||||
<h2>Channel</h2>
|
||||
<form method="POST" action="/settings/notifications">
|
||||
<form method="POST" action="/settings/notifications">
|
||||
|
||||
<!-- Channel selector -->
|
||||
<div class="section">
|
||||
<h2>Channel</h2>
|
||||
<div class="field">
|
||||
<label for="notification_channel">Notification channel</label>
|
||||
<label for="notification_channel">Default outbound channel</label>
|
||||
<select id="notification_channel" name="notification_channel"
|
||||
data-value="{{ notify_channel }}">
|
||||
<option value="">None (disabled)</option>
|
||||
@@ -193,36 +237,118 @@
|
||||
<option value="nextcloud">Nextcloud Talk</option>
|
||||
<option value="google_chat">Google Chat</option>
|
||||
</select>
|
||||
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="notification_email">Email override
|
||||
<label for="notification_email">Email address override
|
||||
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
|
||||
</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
value="{{ notify_email_override }}"
|
||||
placeholder="Leave blank to use login email"
|
||||
placeholder="Leave blank to use your login email"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_notification_room">Nextcloud Talk room token</label>
|
||||
<input type="text" id="nc_notification_room" name="nc_notification_room"
|
||||
value="{{ nc_notify_room }}"
|
||||
placeholder="Token from the Talk room URL"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
|
||||
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
|
||||
value="{{ gc_webhook }}"
|
||||
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<button type="submit">Save notification settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud Talk -->
|
||||
<div class="section">
|
||||
<h2>Nextcloud Talk</h2>
|
||||
<p class="hint" style="margin-bottom:1rem;">
|
||||
Configure to send and receive messages via your Nextcloud Talk bot.
|
||||
<strong>Sending</strong> requires the bot URL, secret, and notification room.
|
||||
<strong>Reading history</strong> (<code>nc_talk_history</code> tool) additionally
|
||||
requires a Nextcloud username and app password.
|
||||
</p>
|
||||
|
||||
<details class="channel-block" {{ nc_url and 'open' or '' }}>
|
||||
<summary>Bot credentials (sending)</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
Set these up in your Nextcloud Talk room → Bot settings.
|
||||
See the <a href="/help" style="color:#a78bfa;">setup guide</a> for step-by-step instructions.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="nc_url">Nextcloud URL</label>
|
||||
<input type="url" id="nc_url" name="nc_url"
|
||||
value="{{ nc_url }}"
|
||||
placeholder="https://cloud.example.com"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_bot_secret">Bot secret</label>
|
||||
<input type="password" id="nc_bot_secret" name="nc_bot_secret"
|
||||
value="{{ nc_bot_secret }}"
|
||||
placeholder="Leave blank to keep existing value"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
<p class="hint">Generated when you registered the bot in Nextcloud Talk.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_notification_room">Notification room token</label>
|
||||
<input type="text" id="nc_notification_room" name="nc_notification_room"
|
||||
value="{{ nc_notify_room }}"
|
||||
placeholder="Token from the Talk room URL"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<p class="hint">The token at the end of the Talk room URL — e.g. <code>abc123def</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="channel-block" {{ nc_username and 'open' or '' }}>
|
||||
<summary>API credentials (reading history)</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
Required for the <code>nc_talk_history</code> orchestrator tool.
|
||||
Generate an app password in Nextcloud → Settings → Security → App passwords.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="nc_username">Nextcloud username</label>
|
||||
<input type="text" id="nc_username" name="nc_username"
|
||||
value="{{ nc_username }}"
|
||||
placeholder="Your Nextcloud login username"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_app_password">App password</label>
|
||||
<input type="password" id="nc_app_password" name="nc_app_password"
|
||||
value="{{ nc_app_password }}"
|
||||
placeholder="Leave blank to keep existing value"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Google Chat -->
|
||||
<div class="section">
|
||||
<h2>Google Chat</h2>
|
||||
<p class="hint" style="margin-bottom:1rem;">
|
||||
Outbound webhook for proactive messages to a Google Chat space.
|
||||
Incoming messages are handled separately via the Google Chat Add-on.
|
||||
</p>
|
||||
|
||||
<details class="channel-block" {{ gc_webhook and 'open' or '' }}>
|
||||
<summary>Outbound webhook</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
Create a webhook in your Google Chat space → Manage webhooks.
|
||||
Paste the full URL here.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="gc_outbound_webhook">Webhook URL</label>
|
||||
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
|
||||
value="{{ gc_webhook }}"
|
||||
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save notification settings</button>
|
||||
</form>
|
||||
|
||||
<!-- Test -->
|
||||
<div class="section">
|
||||
<div class="section" style="margin-top:2rem;">
|
||||
<h2>Test</h2>
|
||||
<p class="hint" style="margin-bottom:0.85rem">
|
||||
Fire a notification via your configured channel or run the reminder check
|
||||
|
||||
@@ -345,6 +345,25 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- HTTP POST Allowlist -->
|
||||
<div class="section">
|
||||
<h2>HTTP POST Allowlist</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
One URL prefix per line. The <code style="font-size:0.82rem; background:var(--pg-bg); padding:0.1rem 0.35rem; border-radius:4px;">http_post</code>
|
||||
tool will only POST to URLs that start with a listed prefix.
|
||||
Leave blank to block all outbound POST requests.
|
||||
</p>
|
||||
<form method="POST" action="/settings/http-allowlist">
|
||||
<div class="field">
|
||||
<label for="http_allowlist_ta">Allowed URL prefixes</label>
|
||||
<textarea id="http_allowlist_ta" name="prefixes" rows="5"
|
||||
placeholder="https://ha.dgrzone.com/api/webhook/ https://n8n.dgrzone.com/webhook/"
|
||||
spellcheck="false">{{ http_allowlist }}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="section">
|
||||
<h2>Notifications</h2>
|
||||
|
||||
Reference in New Issue
Block a user