Improve security
This commit is contained in:
@@ -400,9 +400,12 @@
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
|
||||
<a class="navbar-item" href="{% url 'security_settings' %}">
|
||||
Security
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
|
||||
2FA
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
|
||||
Notifications
|
||||
</a>
|
||||
|
||||
695
core/templates/pages/security.html
Normal file
695
core/templates/pages/security.html
Normal file
@@ -0,0 +1,695 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Security</h1>
|
||||
|
||||
<div class="columns is-desktop is-variable is-8">
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<h2 class="title is-6">XMPP Channel</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Component JID</th>
|
||||
<td>{{ xmpp_state.omemo_target_jid|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OMEMO</th>
|
||||
<td>
|
||||
{% if xmpp_state.omemo_enabled %}
|
||||
<span class="tag is-success">Active</span>
|
||||
{% else %}
|
||||
<span class="tag is-light">{{ xmpp_state.omemo_status|default:"not configured" }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status reason</th>
|
||||
<td>{{ xmpp_state.omemo_status_reason|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Component fingerprint</th>
|
||||
<td><code>{{ xmpp_state.omemo_fingerprint|default:"—" }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 class="title is-7 mt-4 mb-2">Security Policy</h3>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="require_omemo"{% if security_settings.require_omemo %} checked{% endif %}>
|
||||
Require OMEMO encryption — reject plaintext messages from your XMPP client
|
||||
</label>
|
||||
<p class="help is-size-7 has-text-grey mt-1">When enabled, any plaintext XMPP message to the gateway is rejected before command routing.</p>
|
||||
<p class="help is-size-7 has-text-grey">This is separate from command-scope policy checks such as Require Trusted Fingerprint.</p>
|
||||
</div>
|
||||
<button class="button is-link is-small" type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Your XMPP Client</h2>
|
||||
{% if omemo_row %}
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td>
|
||||
{% if omemo_row.status == "detected" %}
|
||||
<span class="tag is-info">{{ omemo_row.status }}</span>
|
||||
{% elif omemo_row.status == "no_omemo" %}
|
||||
<span class="tag is-warning">no OMEMO observed</span>
|
||||
{% elif omemo_row.status == "error" %}
|
||||
<span class="tag is-danger">{{ omemo_row.status }}</span>
|
||||
{% else %}
|
||||
<span class="tag is-light">{{ omemo_row.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contact key</th>
|
||||
<td><code>{{ omemo_row.latest_client_key|default:"—" }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contact JID</th>
|
||||
<td>{{ sender_jid.bare|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<td>{{ sender_jid.resource|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>GIA component</th>
|
||||
<td>{{ omemo_row.last_target_jid|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last seen</th>
|
||||
<td>{{ omemo_row.last_seen_at|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated</th>
|
||||
<td>{{ omemo_row.updated_at }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="is-size-7 has-text-grey">No OMEMO observation recorded yet. Send a message via XMPP to populate this.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Global Scope Override</h2>
|
||||
<p class="is-size-7 has-text-grey mb-3">
|
||||
This scope can force settings across all Command Security Scopes.
|
||||
</p>
|
||||
<div class="box" style="margin: 0; border: 1px solid rgba(60, 60, 60, 0.12);">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="scope_key" value="global.override">
|
||||
<input type="hidden" name="global_scope_enabled" value="{{ global_override.values.scope_enabled }}" data-global-mode-input="scope_enabled">
|
||||
<input type="hidden" name="global_require_omemo" value="{{ global_override.values.require_omemo }}" data-global-mode-input="require_omemo">
|
||||
<input type="hidden" name="global_require_trusted_fingerprint" value="{{ global_override.values.require_trusted_fingerprint }}" data-global-mode-input="require_trusted_fingerprint">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||
<div>
|
||||
<p class="mb-1"><strong>Global Scope Override</strong></p>
|
||||
<p class="is-size-7 has-text-grey">Remote controls for local scope security checkboxes.</p>
|
||||
<p class="is-size-7 has-text-grey"><code>global.override</code></p>
|
||||
</div>
|
||||
<div class="tags has-addons">
|
||||
<span class="tag is-dark">Scope Enabled</span>
|
||||
{% if global_override.values.scope_enabled == "on" %}
|
||||
<span class="tag is-success">Force On</span>
|
||||
{% elif global_override.values.scope_enabled == "off" %}
|
||||
<span class="tag is-light">Force Off</span>
|
||||
{% else %}
|
||||
<span class="tag is-info">Per Scope</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Services Preview</label>
|
||||
<div class="scope-allowance-capsule" data-capsule>
|
||||
{% for service in policy_services %}
|
||||
<span class="scope-allowance-pill" data-service-pill="{{ service }}">{{ service }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped is-grouped-multiline mb-2">
|
||||
<label class="checkbox mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
data-global-visual-checkbox="scope_enabled"
|
||||
data-global-visual-state="{{ global_override.values.scope_enabled }}"
|
||||
{% if global_override.values.scope_enabled == "on" %} checked{% endif %}
|
||||
>
|
||||
Scope Enabled
|
||||
</label>
|
||||
<label class="checkbox mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
data-global-visual-checkbox="require_omemo"
|
||||
data-global-visual-state="{{ global_override.values.require_omemo }}"
|
||||
{% if global_override.values.require_omemo == "on" %} checked{% endif %}
|
||||
>
|
||||
Require OMEMO
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
data-global-visual-checkbox="require_trusted_fingerprint"
|
||||
{% if global_override.values.require_trusted_fingerprint == "on" %} checked{% endif %}
|
||||
>
|
||||
Require Trusted Fingerprint
|
||||
</label>
|
||||
</div>
|
||||
<p class="help is-size-7 has-text-grey mb-3">Set each field to Per Scope to edit that field inside local scopes.</p>
|
||||
|
||||
<div class="field mb-2">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<label class="label is-size-7 mb-1">Scope Enabled</label>
|
||||
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||
<span class="tag is-size-7" data-global-mode-label="scope_enabled"></span>
|
||||
<button type="button" class="button is-small is-light" data-global-change-toggle="scope_enabled">Change Global</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons has-addons is-hidden" data-global-mode-picker="scope_enabled">
|
||||
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="per_scope">Per Scope</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="on">Force On</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="off">Force Off</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field mb-2">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<label class="label is-size-7 mb-1">Require OMEMO</label>
|
||||
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||
<span class="tag is-size-7" data-global-mode-label="require_omemo"></span>
|
||||
<button type="button" class="button is-small is-light" data-global-change-toggle="require_omemo">Change Global</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons has-addons is-hidden" data-global-mode-picker="require_omemo">
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="per_scope">Per Scope</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="on">Force On</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="off">Force Off</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<label class="label is-size-7 mb-1">Require Trusted Fingerprint</label>
|
||||
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||
<span class="tag is-size-7" data-global-mode-label="require_trusted_fingerprint"></span>
|
||||
<button type="button" class="button is-small is-light" data-global-change-toggle="require_trusted_fingerprint">Change Global</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons has-addons is-hidden" data-global-mode-picker="require_trusted_fingerprint">
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="per_scope">Per Scope</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="on">Force On</button>
|
||||
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="off">Force Off</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Services</label>
|
||||
<div class="is-flex is-flex-wrap-wrap" style="gap: 0.8rem;">
|
||||
{% for service in policy_services %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allowed_services" value="{{ service }}"{% if service in global_override.allowed_services %} checked{% endif %}>
|
||||
{{ service }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="help is-size-7 has-text-grey">Allowed Services: <code>xmpp</code>, <code>whatsapp</code>, <code>signal</code>, <code>instagram</code>, <code>web</code>.</p>
|
||||
<p class="help is-size-7 has-text-grey">Leave all unchecked to allow all services.</p>
|
||||
</div>
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Channels</label>
|
||||
<div id="channel-rules-global-override" class="channel-rules-list">
|
||||
{% for rule in global_override.channel_rules %}
|
||||
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||
<div class="select is-small">
|
||||
<select name="allowed_channel_service">
|
||||
<option value="*"{% if rule.service == "*" %} selected{% endif %}>any</option>
|
||||
{% for service in policy_services %}
|
||||
<option value="{{ service }}"{% if rule.service == service %} selected{% endif %}>{{ service }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input is-small" name="allowed_channel_pattern" value="{{ rule.pattern }}" placeholder="m@zm.is* or 1203*">
|
||||
<button class="button is-small is-light is-danger channel-rule-remove" type="button">Remove</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button
|
||||
class="button is-small is-light channel-rule-add"
|
||||
type="button"
|
||||
data-target="channel-rules-global-override"
|
||||
>
|
||||
Add Channel Rule
|
||||
</button>
|
||||
<p class="help is-size-7 has-text-grey">Leave pattern rows empty to allow all channels for allowed services.</p>
|
||||
</div>
|
||||
<button class="button is-link is-small" type="submit">Save Scope</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Command Security Scopes</h2>
|
||||
<p class="is-size-7 has-text-grey mb-3">
|
||||
Choose a top-level category, expand a scope, then click <strong>Change</strong> to edit that scope.
|
||||
</p>
|
||||
<div class="tabs is-toggle is-toggle-rounded is-small mb-3">
|
||||
<ul>
|
||||
{% for group in policy_groups %}
|
||||
<li class="{% if forloop.first %}is-active{% endif %}" data-policy-tab-button="{{ group.key }}">
|
||||
<a>{{ group.label }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% for group in policy_groups %}
|
||||
<div class="policy-tab-panel{% if forloop.first %} is-active{% endif %}" data-policy-tab-panel="{{ group.key }}">
|
||||
<div class="is-flex is-flex-direction-column" style="gap: 1rem;">
|
||||
{% for row in group.rows %}
|
||||
<details class="box scope-editor-card" style="margin: 0; border: 1px solid rgba(60, 60, 60, 0.12);">
|
||||
<summary class="scope-summary is-flex is-justify-content-space-between is-align-items-center">
|
||||
<span>
|
||||
<strong>{{ row.label }}</strong>
|
||||
<span class="is-size-7 has-text-grey ml-2"><code>{{ row.scope_key }}</code></span>
|
||||
</span>
|
||||
<span class="tags has-addons">
|
||||
<span class="tag is-dark">Scope Enabled</span>
|
||||
{% if row.enabled %}
|
||||
<span class="tag is-success">On</span>
|
||||
{% else %}
|
||||
<span class="tag is-light">Off</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</summary>
|
||||
<form method="post" class="mt-3" data-scope-form>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="scope_key" value="{{ row.scope_key }}">
|
||||
<input type="hidden" name="scope_change_mode" value="0" data-scope-change-mode>
|
||||
<p class="is-size-7 has-text-grey mb-2">{{ row.description }}</p>
|
||||
|
||||
<div class="field mb-2">
|
||||
<button class="button is-small is-light" type="button" data-scope-edit-toggle>Change</button>
|
||||
</div>
|
||||
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Services Preview</label>
|
||||
<div class="scope-allowance-capsule" data-capsule>
|
||||
{% for service in policy_services %}
|
||||
<span class="scope-allowance-pill" data-service-pill="{{ service }}">{{ service }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped is-grouped-multiline mb-2">
|
||||
<label class="checkbox mr-4" title="{% if row.enabled_locked %}{{ row.lock_help }}{% endif %}">
|
||||
<input class="scope-editable" data-lock-state="{% if row.enabled_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_enabled"{% if row.enabled %} checked{% endif %}{% if row.enabled_locked %} disabled{% endif %}>
|
||||
Scope Enabled
|
||||
</label>
|
||||
<label class="checkbox mr-4" title="{% if row.require_omemo_locked %}{{ row.lock_help }}{% endif %}">
|
||||
<input class="scope-editable" data-lock-state="{% if row.require_omemo_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_omemo"{% if row.require_omemo %} checked{% endif %}{% if row.require_omemo_locked %} disabled{% endif %}>
|
||||
Require OMEMO
|
||||
</label>
|
||||
<label class="checkbox" title="{% if row.require_trusted_fingerprint_locked %}{{ row.lock_help }}{% endif %}">
|
||||
<input class="scope-editable" data-lock-state="{% if row.require_trusted_fingerprint_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_trusted_fingerprint"{% if row.require_trusted_fingerprint %} checked{% endif %}{% if row.require_trusted_fingerprint_locked %} disabled{% endif %}>
|
||||
Require Trusted Fingerprint
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Services</label>
|
||||
<div class="is-flex is-flex-wrap-wrap" style="gap: 0.8rem;">
|
||||
{% for service in policy_services %}
|
||||
<label class="checkbox">
|
||||
<input class="scope-editable" data-lock-state="free" type="checkbox" name="allowed_services" value="{{ service }}"{% if service in row.allowed_services %} checked{% endif %}>
|
||||
{{ service }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="help is-size-7 has-text-grey">Allowed Services: <code>xmpp</code>, <code>whatsapp</code>, <code>signal</code>, <code>instagram</code>, <code>web</code>.</p>
|
||||
<p class="help is-size-7 has-text-grey">Leave all unchecked to allow all services.</p>
|
||||
</div>
|
||||
|
||||
<div class="field mb-2">
|
||||
<label class="label is-size-7">Allowed Channels</label>
|
||||
<div id="channel-rules-{{ row.scope_key|slugify }}" class="channel-rules-list">
|
||||
{% for rule in row.channel_rules %}
|
||||
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||
<div class="select is-small">
|
||||
<select class="scope-editable" data-lock-state="free" name="allowed_channel_service">
|
||||
<option value="*"{% if rule.service == "*" %} selected{% endif %}>any</option>
|
||||
{% for service in policy_services %}
|
||||
<option value="{{ service }}"{% if rule.service == service %} selected{% endif %}>{{ service }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input is-small scope-editable" data-lock-state="free" name="allowed_channel_pattern" value="{{ rule.pattern }}" placeholder="m@zm.is* or 1203*">
|
||||
<button class="button is-small is-light is-danger channel-rule-remove scope-editable" data-lock-state="free" type="button">Remove</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button
|
||||
class="button is-small is-light channel-rule-add scope-editable"
|
||||
data-lock-state="free"
|
||||
type="button"
|
||||
data-target="channel-rules-{{ row.scope_key|slugify }}"
|
||||
>
|
||||
Add Channel Rule
|
||||
</button>
|
||||
<p class="help is-size-7 has-text-grey">Leave pattern rows empty to allow all channels for allowed services.</p>
|
||||
</div>
|
||||
|
||||
<button class="button is-link is-small" type="submit" data-scope-save>Save Scope</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">OMEMO Enablement Plan</h2>
|
||||
<p class="is-size-7 has-text-grey mb-3">Complete each step to achieve end-to-end encrypted messaging with the gateway.</p>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<tbody>
|
||||
{% for step in omemo_plan %}
|
||||
<tr>
|
||||
<td style="width:2.5rem">
|
||||
{% if step.done %}
|
||||
<span class="tag is-success">✓</span>
|
||||
{% else %}
|
||||
<span class="tag is-warning">○</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{{ step.label }}</strong></td>
|
||||
<td class="has-text-grey">{{ step.hint }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<template id="channel-rule-template">
|
||||
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||
<div class="select is-small">
|
||||
<select class="scope-editable" data-lock-state="free" name="allowed_channel_service">
|
||||
<option value="*">any</option>
|
||||
{% for service in policy_services %}
|
||||
<option value="{{ service }}">{{ service }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input is-small scope-editable" data-lock-state="free" name="allowed_channel_pattern" value="" placeholder="m@zm.is* or 1203*">
|
||||
<button class="button is-small is-light is-danger channel-rule-remove scope-editable" data-lock-state="free" type="button">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
(function () {
|
||||
function wireRemoveButtons(scope) {
|
||||
scope.querySelectorAll(".channel-rule-remove").forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
const row = btn.closest(".channel-rule-row");
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function refreshScopeCapsule(form) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const capsule = form.querySelector("[data-capsule]");
|
||||
if (!capsule) {
|
||||
return;
|
||||
}
|
||||
const enabledInput = form.querySelector('input[name="policy_enabled"]');
|
||||
let enabled = !!(enabledInput && enabledInput.checked);
|
||||
if (!enabledInput) {
|
||||
const globalModeInput = form.querySelector('input[name="global_scope_enabled"]');
|
||||
if (globalModeInput) {
|
||||
enabled = String(globalModeInput.value || "").toLowerCase() !== "off";
|
||||
} else {
|
||||
enabled = true;
|
||||
}
|
||||
}
|
||||
const serviceChecks = Array.from(
|
||||
form.querySelectorAll('input[name="allowed_services"]')
|
||||
);
|
||||
const selected = serviceChecks
|
||||
.filter(function (el) { return el.checked; })
|
||||
.map(function (el) { return String(el.value || "").trim().toLowerCase(); })
|
||||
.filter(Boolean);
|
||||
const hasServiceRestriction = selected.length > 0;
|
||||
|
||||
capsule.querySelectorAll("[data-service-pill]").forEach(function (pill) {
|
||||
const service = String(pill.getAttribute("data-service-pill") || "").trim().toLowerCase();
|
||||
const serviceAllowed = !hasServiceRestriction || selected.includes(service);
|
||||
const allowed = enabled && serviceAllowed;
|
||||
pill.classList.remove("is-allowed", "is-blocked");
|
||||
pill.classList.add(allowed ? "is-allowed" : "is-blocked");
|
||||
pill.textContent = (service || "service") + (allowed ? " allowed" : " blocked");
|
||||
});
|
||||
}
|
||||
|
||||
wireRemoveButtons(document);
|
||||
|
||||
const template = document.getElementById("channel-rule-template");
|
||||
document.querySelectorAll(".channel-rule-add").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const targetId = btn.getAttribute("data-target");
|
||||
const container = targetId ? document.getElementById(targetId) : null;
|
||||
if (!container || !template) {
|
||||
return;
|
||||
}
|
||||
const fragment = template.content.cloneNode(true);
|
||||
container.appendChild(fragment);
|
||||
wireRemoveButtons(container);
|
||||
const scopeForm = btn.closest("form[data-scope-form]");
|
||||
if (scopeForm) {
|
||||
applyScopeEditState(scopeForm);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("form").forEach(function (form) {
|
||||
form.querySelectorAll('input[name="policy_enabled"], input[name="allowed_services"]').forEach(function (input) {
|
||||
input.addEventListener("change", function () {
|
||||
refreshScopeCapsule(form);
|
||||
});
|
||||
});
|
||||
refreshScopeCapsule(form);
|
||||
});
|
||||
|
||||
function applyScopeEditState(form) {
|
||||
if (!form || !form.matches("[data-scope-form]")) {
|
||||
return;
|
||||
}
|
||||
const editing = String(form.getAttribute("data-editing") || "0") === "1";
|
||||
form.querySelectorAll(".scope-editable").forEach(function (input) {
|
||||
const locked = String(input.getAttribute("data-lock-state") || "").toLowerCase() === "locked";
|
||||
input.disabled = locked || !editing;
|
||||
});
|
||||
const toggle = form.querySelector("[data-scope-edit-toggle]");
|
||||
if (toggle) {
|
||||
toggle.textContent = editing ? "Cancel Change" : "Change";
|
||||
}
|
||||
const changeMode = form.querySelector("[data-scope-change-mode]");
|
||||
if (changeMode) {
|
||||
changeMode.value = editing ? "1" : "0";
|
||||
}
|
||||
const saveButton = form.querySelector("[data-scope-save]");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = !editing;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("form[data-scope-form]").forEach(function (form) {
|
||||
form.setAttribute("data-editing", "0");
|
||||
applyScopeEditState(form);
|
||||
const toggle = form.querySelector("[data-scope-edit-toggle]");
|
||||
if (toggle) {
|
||||
toggle.addEventListener("click", function () {
|
||||
const editing = String(form.getAttribute("data-editing") || "0") === "1";
|
||||
form.setAttribute("data-editing", editing ? "0" : "1");
|
||||
applyScopeEditState(form);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function updateGlobalModeUI(field) {
|
||||
const hiddenInput = document.querySelector('input[data-global-mode-input="' + field + '"]');
|
||||
const checkbox = document.querySelector('input[data-global-visual-checkbox="' + field + '"]');
|
||||
const label = document.querySelector('[data-global-mode-label="' + field + '"]');
|
||||
if (!hiddenInput || !checkbox || !label) {
|
||||
return;
|
||||
}
|
||||
const mode = String(hiddenInput.value || "").toLowerCase();
|
||||
checkbox.checked = mode === "on";
|
||||
checkbox.indeterminate = mode === "per_scope";
|
||||
|
||||
label.classList.remove("is-success", "is-light", "is-info");
|
||||
if (mode === "on") {
|
||||
label.classList.add("is-success");
|
||||
label.textContent = "Force On";
|
||||
} else if (mode === "off") {
|
||||
label.classList.add("is-light");
|
||||
label.textContent = "Force Off";
|
||||
} else {
|
||||
label.classList.add("is-info");
|
||||
label.textContent = "Per Scope";
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-global-mode-set="' + field + '"]').forEach(function (btn) {
|
||||
const selected = String(btn.getAttribute("data-mode") || "").toLowerCase() === mode;
|
||||
btn.classList.toggle("is-link", selected);
|
||||
});
|
||||
}
|
||||
|
||||
function setGlobalMode(field, mode) {
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
const hiddenInput = document.querySelector('input[data-global-mode-input="' + field + '"]');
|
||||
if (!hiddenInput) {
|
||||
return;
|
||||
}
|
||||
hiddenInput.value = mode;
|
||||
updateGlobalModeUI(field);
|
||||
const form = hiddenInput.closest("form");
|
||||
refreshScopeCapsule(form);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-global-change-toggle]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const field = String(btn.getAttribute("data-global-change-toggle") || "");
|
||||
const picker = document.querySelector('[data-global-mode-picker="' + field + '"]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
const currentlyHidden = picker.classList.contains("is-hidden");
|
||||
document.querySelectorAll("[data-global-mode-picker]").forEach(function (row) {
|
||||
row.classList.add("is-hidden");
|
||||
});
|
||||
if (currentlyHidden) {
|
||||
picker.classList.remove("is-hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-global-mode-set]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const field = String(btn.getAttribute("data-global-mode-set") || "");
|
||||
const mode = String(btn.getAttribute("data-mode") || "");
|
||||
setGlobalMode(field, mode);
|
||||
const picker = document.querySelector('[data-global-mode-picker="' + field + '"]');
|
||||
if (picker) {
|
||||
picker.classList.add("is-hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
["scope_enabled", "require_omemo", "require_trusted_fingerprint"].forEach(function (field) {
|
||||
updateGlobalModeUI(field);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-policy-tab-button]").forEach(function (tab) {
|
||||
tab.addEventListener("click", function () {
|
||||
const key = String(tab.getAttribute("data-policy-tab-button") || "");
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll("[data-policy-tab-button]").forEach(function (node) {
|
||||
node.classList.remove("is-active");
|
||||
});
|
||||
document.querySelectorAll("[data-policy-tab-panel]").forEach(function (panel) {
|
||||
panel.classList.remove("is-active");
|
||||
});
|
||||
tab.classList.add("is-active");
|
||||
const target = document.querySelector('[data-policy-tab-panel="' + key + '"]');
|
||||
if (target) {
|
||||
target.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".scope-editor-card").forEach(function (details) {
|
||||
details.addEventListener("toggle", function () {
|
||||
if (details.open) {
|
||||
const form = details.querySelector("form[data-scope-form]");
|
||||
if (form) {
|
||||
refreshScopeCapsule(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.policy-tab-panel {
|
||||
display: none;
|
||||
}
|
||||
.policy-tab-panel.is-active {
|
||||
display: block;
|
||||
}
|
||||
.scope-summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.scope-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.scope-allowance-capsule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.scope-allowance-pill {
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scope-allowance-pill.is-allowed {
|
||||
background: rgba(72, 199, 142, 0.18);
|
||||
color: #1f684a;
|
||||
border-color: rgba(72, 199, 142, 0.28);
|
||||
}
|
||||
.scope-allowance-pill.is-blocked {
|
||||
background: rgba(241, 70, 104, 0.16);
|
||||
color: #7d1f39;
|
||||
border-color: rgba(241, 70, 104, 0.24);
|
||||
}
|
||||
[data-theme="dark"] .scope-allowance-pill.is-allowed {
|
||||
background: rgba(72, 199, 142, 0.26);
|
||||
color: #c7f3df;
|
||||
border-color: rgba(72, 199, 142, 0.45);
|
||||
}
|
||||
[data-theme="dark"] .scope-allowance-pill.is-blocked {
|
||||
background: rgba(241, 70, 104, 0.24);
|
||||
color: #ffd0db;
|
||||
border-color: rgba(241, 70, 104, 0.42);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -158,12 +158,37 @@
|
||||
<td>{{ row.status_snapshot }}</td>
|
||||
<td>
|
||||
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
|
||||
</form>
|
||||
{% if enabled_providers|length == 1 %}
|
||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
|
||||
<button class="button is-small is-link is-light" type="submit">
|
||||
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% elif enabled_providers %}
|
||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||
<div class="field has-addons" style="display:inline-flex;">
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select name="provider">
|
||||
{% for p in enabled_providers %}
|
||||
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-link is-light" type="submit">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -413,6 +413,63 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<hr>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="provider_update">
|
||||
<input type="hidden" name="provider" value="claude_cli">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if claude_provider_config and claude_provider_config.enabled %}checked{% endif %}> Enable Claude CLI provider</label>
|
||||
<p class="help">Claude task-sync runs in the same dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
||||
<p class="help">This provider config is global per-user and shared across all projects/chats.</p>
|
||||
<div class="field" style="margin-top:0.5rem;">
|
||||
<label class="label is-size-7">Command</label>
|
||||
<input class="input is-small" name="command" value="{{ claude_provider_settings.command }}" placeholder="claude">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Workspace Root</label>
|
||||
<input class="input is-small" name="workspace_root" value="{{ claude_provider_settings.workspace_root }}" placeholder="/code/xf">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Default Profile</label>
|
||||
<input class="input is-small" name="default_profile" value="{{ claude_provider_settings.default_profile }}" placeholder="default">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Timeout Seconds</label>
|
||||
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ claude_provider_settings.timeout_seconds }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Approver Service</label>
|
||||
<input class="input is-small" name="approver_service" value="{{ claude_provider_settings.approver_service }}" placeholder="signal">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Approver Identifier</label>
|
||||
<input class="input is-small" name="approver_identifier" value="{{ claude_provider_settings.approver_identifier }}" placeholder="+15550000001">
|
||||
</div>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button class="button is-small is-link is-light" type="submit">Save Claude Provider</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<article class="box" style="margin-top:0.5rem;">
|
||||
<h4 class="title is-7">Claude Compact Summary</h4>
|
||||
<p class="help">
|
||||
Health:
|
||||
{% if claude_compact_summary.healthcheck_ok %}
|
||||
<span class="tag is-success is-light">online</span>
|
||||
{% else %}
|
||||
<span class="tag is-danger is-light">offline</span>
|
||||
{% endif %}
|
||||
{% if claude_compact_summary.healthcheck_error %}
|
||||
<code>{{ claude_compact_summary.healthcheck_error }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag is-light">pending {{ claude_compact_summary.queue_counts.pending }}</span>
|
||||
<span class="tag is-warning is-light">waiting_approval {{ claude_compact_summary.queue_counts.waiting_approval }}</span>
|
||||
<span class="tag is-danger is-light">failed {{ claude_compact_summary.queue_counts.failed }}</span>
|
||||
<span class="tag is-success is-light">ok {{ claude_compact_summary.queue_counts.ok }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user