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:
|
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||||
html = (_STATIC / "notifications.html").read_text()
|
html = (_STATIC / "notifications.html").read_text()
|
||||||
channels = get_user_channels(username)
|
channels = get_user_channels(username)
|
||||||
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
nct = channels.get("nextcloud") or {}
|
||||||
notify_email = _html.escape(channels.get("notification_email", "") or "")
|
|
||||||
nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "")
|
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
||||||
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") 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_channel }}", notify_ch)
|
||||||
html = html.replace("{{ notify_email_override }}", notify_email)
|
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_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("{{ gc_webhook }}", gc_webhook)
|
||||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
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")
|
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 = ""
|
allowlist_text = ""
|
||||||
html = html.replace("{{ email_allowlist }}", 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
|
# Tool permission policy
|
||||||
policy = get_tool_policy(username)
|
policy = get_tool_policy(username)
|
||||||
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
|
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
|
||||||
@@ -284,7 +303,11 @@ async def save_notifications(
|
|||||||
request: Request,
|
request: Request,
|
||||||
notification_channel: str = Form(""),
|
notification_channel: str = Form(""),
|
||||||
notification_email: str = Form(""),
|
notification_email: str = Form(""),
|
||||||
|
nc_url: str = Form(""),
|
||||||
|
nc_bot_secret: str = Form(""),
|
||||||
nc_notification_room: str = Form(""),
|
nc_notification_room: str = Form(""),
|
||||||
|
nc_username: str = Form(""),
|
||||||
|
nc_app_password: str = Form(""),
|
||||||
gc_outbound_webhook: str = Form(""),
|
gc_outbound_webhook: str = Form(""),
|
||||||
):
|
):
|
||||||
username = _get_session_user(request)
|
username = _get_session_user(request)
|
||||||
@@ -313,10 +336,20 @@ async def save_notifications(
|
|||||||
else:
|
else:
|
||||||
channels.pop("notification_email", None)
|
channels.pop("notification_email", None)
|
||||||
|
|
||||||
# NC Talk notification room — nested under "nextcloud"
|
# Nextcloud Talk — full config nested under "nextcloud"
|
||||||
if "nextcloud" not in channels:
|
if "nextcloud" not in channels:
|
||||||
channels["nextcloud"] = {}
|
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"
|
# Google Chat outbound webhook — nested under "google_chat"
|
||||||
if "google_chat" not in channels:
|
if "google_chat" not in channels:
|
||||||
@@ -365,3 +398,21 @@ async def save_email_allowlist(
|
|||||||
path.write_text(json.dumps(lines, indent=2))
|
path.write_text(json.dumps(lines, indent=2))
|
||||||
logger.info("email allowlist updated for %s (%d patterns)", username, len(lines))
|
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 ''})."))
|
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;
|
border-radius: 12px;
|
||||||
padding: 2.5rem 2rem;
|
padding: 2.5rem 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-nav {
|
.page-nav {
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
input:focus, select:focus { border-color: #7c3aed; }
|
input:focus, select:focus { border-color: #7c3aed; }
|
||||||
|
input[type="password"] { font-family: monospace; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
.field { margin-bottom: 1rem; }
|
.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.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); }
|
.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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -179,12 +222,13 @@
|
|||||||
<!-- SUCCESS -->
|
<!-- SUCCESS -->
|
||||||
<!-- ERROR -->
|
<!-- ERROR -->
|
||||||
|
|
||||||
<!-- Channel config -->
|
<form method="POST" action="/settings/notifications">
|
||||||
<div class="section">
|
|
||||||
<h2>Channel</h2>
|
<!-- Channel selector -->
|
||||||
<form method="POST" action="/settings/notifications">
|
<div class="section">
|
||||||
|
<h2>Channel</h2>
|
||||||
<div class="field">
|
<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"
|
<select id="notification_channel" name="notification_channel"
|
||||||
data-value="{{ notify_channel }}">
|
data-value="{{ notify_channel }}">
|
||||||
<option value="">None (disabled)</option>
|
<option value="">None (disabled)</option>
|
||||||
@@ -193,36 +237,118 @@
|
|||||||
<option value="nextcloud">Nextcloud Talk</option>
|
<option value="nextcloud">Nextcloud Talk</option>
|
||||||
<option value="google_chat">Google Chat</option>
|
<option value="google_chat">Google Chat</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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>
|
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="email" id="notification_email" name="notification_email"
|
<input type="email" id="notification_email" name="notification_email"
|
||||||
value="{{ notify_email_override }}"
|
value="{{ notify_email_override }}"
|
||||||
placeholder="Leave blank to use login email"
|
placeholder="Leave blank to use your login email"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
</div>
|
||||||
<label for="nc_notification_room">Nextcloud Talk room token</label>
|
|
||||||
<input type="text" id="nc_notification_room" name="nc_notification_room"
|
<!-- Nextcloud Talk -->
|
||||||
value="{{ nc_notify_room }}"
|
<div class="section">
|
||||||
placeholder="Token from the Talk room URL"
|
<h2>Nextcloud Talk</h2>
|
||||||
autocomplete="off" spellcheck="false">
|
<p class="hint" style="margin-bottom:1rem;">
|
||||||
</div>
|
Configure to send and receive messages via your Nextcloud Talk bot.
|
||||||
<div class="field">
|
<strong>Sending</strong> requires the bot URL, secret, and notification room.
|
||||||
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
|
<strong>Reading history</strong> (<code>nc_talk_history</code> tool) additionally
|
||||||
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
|
requires a Nextcloud username and app password.
|
||||||
value="{{ gc_webhook }}"
|
</p>
|
||||||
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
|
||||||
autocomplete="off" spellcheck="false">
|
<details class="channel-block" {{ nc_url and 'open' or '' }}>
|
||||||
</div>
|
<summary>Bot credentials (sending)</summary>
|
||||||
<button type="submit">Save notification settings</button>
|
<div class="channel-block-body">
|
||||||
</form>
|
<p class="channel-hint">
|
||||||
</div>
|
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 -->
|
<!-- Test -->
|
||||||
<div class="section">
|
<div class="section" style="margin-top:2rem;">
|
||||||
<h2>Test</h2>
|
<h2>Test</h2>
|
||||||
<p class="hint" style="margin-bottom:0.85rem">
|
<p class="hint" style="margin-bottom:0.85rem">
|
||||||
Fire a notification via your configured channel or run the reminder check
|
Fire a notification via your configured channel or run the reminder check
|
||||||
|
|||||||
@@ -345,6 +345,25 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- Notifications -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Notifications</h2>
|
<h2>Notifications</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user