feat: Add smart action buttons and fix CrowdSec settings display (v0.6.0-r29)
- Add service control RPCD method (start/stop/restart/reload) - Add smart action buttons to CrowdSec Settings (Service Control, Register Bouncer, Hub Update) - Add CrowdSec Console quick access link button - Fix LAPI status check (use lapi_status field) - Fix collections display (handle nested response structure) - Fix System Hub Quick Status Indicators layout (label/value stacking) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5b6d1dd87
commit
327cc5b285
@ -347,7 +347,9 @@
|
|||||||
"Bash($SSH root@192.168.255.1 \"uci show network | grep -E ''=interface|\\\\.proto=''\")",
|
"Bash($SSH root@192.168.255.1 \"uci show network | grep -E ''=interface|\\\\.proto=''\")",
|
||||||
"Bash($SSH root@192.168.255.1 \"uci show firewall | grep -E ''zone.*name|=forwarding''\")",
|
"Bash($SSH root@192.168.255.1 \"uci show firewall | grep -E ''zone.*name|=forwarding''\")",
|
||||||
"Bash(for:*)",
|
"Bash(for:*)",
|
||||||
"Bash(do sed -i \"s/''require secubox-theme\\\\/theme as Theme'';//g\" \"$f\")"
|
"Bash(do sed -i \"s/''require secubox-theme\\\\/theme as Theme'';//g\" \"$f\")",
|
||||||
|
"Bash(do sleep 5)",
|
||||||
|
"Bash(break)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,6 +217,13 @@ var callConsoleDisable = rpc.declare({
|
|||||||
expect: { }
|
expect: { }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var callServiceControl = rpc.declare({
|
||||||
|
object: 'luci.crowdsec-dashboard',
|
||||||
|
method: 'service_control',
|
||||||
|
params: ['action'],
|
||||||
|
expect: { }
|
||||||
|
});
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
if (!seconds) return 'N/A';
|
if (!seconds) return 'N/A';
|
||||||
if (seconds < 60) return seconds + 's';
|
if (seconds < 60) return seconds + 's';
|
||||||
@ -343,6 +350,9 @@ return baseclass.extend({
|
|||||||
consoleEnroll: callConsoleEnroll,
|
consoleEnroll: callConsoleEnroll,
|
||||||
consoleDisable: callConsoleDisable,
|
consoleDisable: callConsoleDisable,
|
||||||
|
|
||||||
|
// Service Control
|
||||||
|
serviceControl: callServiceControl,
|
||||||
|
|
||||||
formatDuration: formatDuration,
|
formatDuration: formatDuration,
|
||||||
formatDate: formatDate,
|
formatDate: formatDate,
|
||||||
formatRelativeTime: formatRelativeTime,
|
formatRelativeTime: formatRelativeTime,
|
||||||
|
|||||||
@ -16,9 +16,12 @@ return view.extend({
|
|||||||
|
|
||||||
render: function(data) {
|
render: function(data) {
|
||||||
var status = data[0] || {};
|
var status = data[0] || {};
|
||||||
var machines = data[1] || [];
|
var machinesData = data[1] || {};
|
||||||
|
var machines = Array.isArray(machinesData) ? machinesData : (machinesData.machines || []);
|
||||||
var hub = data[2] || {};
|
var hub = data[2] || {};
|
||||||
var collections = Array.isArray(data[3]) ? data[3] : [];
|
var collectionsData = data[3] || {};
|
||||||
|
var collections = collectionsData.collections || [];
|
||||||
|
if (collections.collections) collections = collections.collections;
|
||||||
|
|
||||||
var view = E('div', { 'class': 'cbi-map' }, [
|
var view = E('div', { 'class': 'cbi-map' }, [
|
||||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||||
@ -42,13 +45,13 @@ return view.extend({
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
// LAPI Status
|
// LAPI Status
|
||||||
E('div', { 'class': 'cbi-value', 'style': 'background: ' + (status.lapi === 'running' ? '#d4edda' : '#f8d7da') + '; padding: 1em; border-radius: 4px; border-left: 4px solid ' + (status.lapi === 'running' ? '#28a745' : '#dc3545') + ';' }, [
|
E('div', { 'class': 'cbi-value', 'style': 'background: ' + (status.lapi_status === 'available' ? '#d4edda' : '#f8d7da') + '; padding: 1em; border-radius: 4px; border-left: 4px solid ' + (status.lapi_status === 'available' ? '#28a745' : '#dc3545') + ';' }, [
|
||||||
E('label', { 'class': 'cbi-value-title' }, _('Local API (LAPI)')),
|
E('label', { 'class': 'cbi-value-title' }, _('Local API (LAPI)')),
|
||||||
E('div', { 'class': 'cbi-value-field' }, [
|
E('div', { 'class': 'cbi-value-field' }, [
|
||||||
E('span', {
|
E('span', {
|
||||||
'class': 'badge',
|
'class': 'badge',
|
||||||
'style': 'background: ' + (status.lapi === 'running' ? '#28a745' : '#dc3545') + '; color: white; padding: 0.5em 1em; border-radius: 4px; font-size: 1em;'
|
'style': 'background: ' + (status.lapi_status === 'available' ? '#28a745' : '#dc3545') + '; color: white; padding: 0.5em 1em; border-radius: 4px; font-size: 1em;'
|
||||||
}, status.lapi === 'running' ? _('RUNNING') : _('STOPPED'))
|
}, status.lapi_status === 'available' ? _('AVAILABLE') : _('UNAVAILABLE'))
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@ -152,7 +155,7 @@ return view.extend({
|
|||||||
E('tbody', {},
|
E('tbody', {},
|
||||||
collections.length > 0 ?
|
collections.length > 0 ?
|
||||||
collections.map(function(collection) {
|
collections.map(function(collection) {
|
||||||
var isInstalled = collection.status === 'installed' || collection.installed === 'ok';
|
var isInstalled = collection.status === 'enabled' || collection.status === 'installed' || collection.installed === 'ok';
|
||||||
var collectionName = collection.name || 'Unknown';
|
var collectionName = collection.name || 'Unknown';
|
||||||
return E('tr', {}, [
|
return E('tr', {}, [
|
||||||
E('td', {}, [
|
E('td', {}, [
|
||||||
@ -228,46 +231,173 @@ return view.extend({
|
|||||||
// Quick Actions
|
// Quick Actions
|
||||||
E('div', { 'class': 'cbi-section', 'style': 'margin-top: 2em;' }, [
|
E('div', { 'class': 'cbi-section', 'style': 'margin-top: 2em;' }, [
|
||||||
E('h3', {}, _('Quick Actions')),
|
E('h3', {}, _('Quick Actions')),
|
||||||
E('div', { 'style': 'display: flex; gap: 1em; flex-wrap: wrap; margin-top: 1em;' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'cbi-button cbi-button-action',
|
|
||||||
'click': function() {
|
|
||||||
ui.showModal(_('Service Control'), [
|
|
||||||
E('p', {}, _('Use the following commands to control CrowdSec:')),
|
|
||||||
E('pre', { 'style': 'background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto;' }, [
|
|
||||||
'/etc/init.d/crowdsec start\n',
|
|
||||||
'/etc/init.d/crowdsec stop\n',
|
|
||||||
'/etc/init.d/crowdsec restart\n',
|
|
||||||
'/etc/init.d/crowdsec status'
|
|
||||||
]),
|
|
||||||
E('div', { 'class': 'right' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn',
|
|
||||||
'click': ui.hideModal
|
|
||||||
}, _('Close'))
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}, _('Service Control')),
|
|
||||||
|
|
||||||
E('button', {
|
// Service Control
|
||||||
'class': 'cbi-button cbi-button-action',
|
E('div', { 'style': 'margin-top: 1em;' }, [
|
||||||
'click': function() {
|
E('h4', { 'style': 'margin-bottom: 0.5em; color: var(--cyber-text-secondary, #888);' }, _('Service Control')),
|
||||||
ui.showModal(_('Register Bouncer'), [
|
E('div', { 'style': 'display: flex; gap: 0.5em; flex-wrap: wrap;' }, [
|
||||||
E('p', {}, _('To register a new bouncer, use the following command:')),
|
E('button', {
|
||||||
E('pre', { 'style': 'background: #f5f5f5; padding: 1em; border-radius: 4px;' },
|
'class': 'cbi-button cbi-button-positive',
|
||||||
'cscli bouncers add <bouncer-name>'),
|
'style': 'min-width: 80px;',
|
||||||
E('p', { 'style': 'margin-top: 1em;' },
|
'click': function(ev) {
|
||||||
_('The command will output an API key. Use this key to configure your bouncer.')),
|
ev.target.disabled = true;
|
||||||
E('div', { 'class': 'right' }, [
|
ev.target.classList.add('spinning');
|
||||||
E('button', {
|
API.serviceControl('start').then(function(result) {
|
||||||
'class': 'btn',
|
ev.target.disabled = false;
|
||||||
'click': ui.hideModal
|
ev.target.classList.remove('spinning');
|
||||||
}, _('Close'))
|
if (result && result.success) {
|
||||||
])
|
ui.addNotification(null, E('p', {}, _('CrowdSec started successfully')), 'info');
|
||||||
]);
|
window.setTimeout(function() { location.reload(); }, 1500);
|
||||||
}
|
} else {
|
||||||
}, _('Register Bouncer'))
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to start service')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('▶ Start')),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-negative',
|
||||||
|
'style': 'min-width: 80px;',
|
||||||
|
'click': function(ev) {
|
||||||
|
ev.target.disabled = true;
|
||||||
|
ev.target.classList.add('spinning');
|
||||||
|
API.serviceControl('stop').then(function(result) {
|
||||||
|
ev.target.disabled = false;
|
||||||
|
ev.target.classList.remove('spinning');
|
||||||
|
if (result && result.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('CrowdSec stopped')), 'info');
|
||||||
|
window.setTimeout(function() { location.reload(); }, 1500);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to stop service')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('■ Stop')),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'style': 'min-width: 80px;',
|
||||||
|
'click': function(ev) {
|
||||||
|
ev.target.disabled = true;
|
||||||
|
ev.target.classList.add('spinning');
|
||||||
|
API.serviceControl('restart').then(function(result) {
|
||||||
|
ev.target.disabled = false;
|
||||||
|
ev.target.classList.remove('spinning');
|
||||||
|
if (result && result.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('CrowdSec restarted')), 'info');
|
||||||
|
window.setTimeout(function() { location.reload(); }, 2000);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to restart service')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('↻ Restart')),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button',
|
||||||
|
'style': 'min-width: 80px;',
|
||||||
|
'click': function(ev) {
|
||||||
|
ev.target.disabled = true;
|
||||||
|
ev.target.classList.add('spinning');
|
||||||
|
API.serviceControl('reload').then(function(result) {
|
||||||
|
ev.target.disabled = false;
|
||||||
|
ev.target.classList.remove('spinning');
|
||||||
|
if (result && result.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Configuration reloaded')), 'info');
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to reload')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('⟳ Reload'))
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Register Bouncer
|
||||||
|
E('div', { 'style': 'margin-top: 1.5em;' }, [
|
||||||
|
E('h4', { 'style': 'margin-bottom: 0.5em; color: var(--cyber-text-secondary, #888);' }, _('Register New Bouncer')),
|
||||||
|
E('div', { 'style': 'display: flex; gap: 0.5em; flex-wrap: wrap; align-items: center;' }, [
|
||||||
|
E('input', {
|
||||||
|
'type': 'text',
|
||||||
|
'id': 'new-bouncer-name',
|
||||||
|
'placeholder': _('Bouncer name...'),
|
||||||
|
'style': 'padding: 0.5em; border: 1px solid var(--cyber-border, #444); border-radius: 4px; background: var(--cyber-bg-secondary, #1a1a2e); color: var(--cyber-text-primary, #fff); min-width: 200px;'
|
||||||
|
}),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-add',
|
||||||
|
'click': function(ev) {
|
||||||
|
var nameInput = document.getElementById('new-bouncer-name');
|
||||||
|
var name = nameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Please enter a bouncer name')), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.target.disabled = true;
|
||||||
|
ev.target.classList.add('spinning');
|
||||||
|
API.registerBouncer(name).then(function(result) {
|
||||||
|
ev.target.disabled = false;
|
||||||
|
ev.target.classList.remove('spinning');
|
||||||
|
if (result && result.success) {
|
||||||
|
nameInput.value = '';
|
||||||
|
ui.showModal(_('Bouncer Registered'), [
|
||||||
|
E('p', {}, _('Bouncer "%s" registered successfully!').format(name)),
|
||||||
|
E('p', { 'style': 'margin-top: 1em;' }, _('API Key:')),
|
||||||
|
E('pre', {
|
||||||
|
'style': 'background: var(--cyber-bg-tertiary, #252538); padding: 1em; border-radius: 4px; word-break: break-all; user-select: all;'
|
||||||
|
}, result.api_key || result.key || 'Check console'),
|
||||||
|
E('p', { 'style': 'margin-top: 1em; color: #f39c12;' },
|
||||||
|
_('Save this key! It will not be shown again.')),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'click': function() {
|
||||||
|
ui.hideModal();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}, _('Close'))
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to register bouncer')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('+ Register'))
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Hub Update
|
||||||
|
E('div', { 'style': 'margin-top: 1.5em;' }, [
|
||||||
|
E('h4', { 'style': 'margin-bottom: 0.5em; color: var(--cyber-text-secondary, #888);' }, _('Hub Management')),
|
||||||
|
E('div', { 'style': 'display: flex; gap: 0.5em; flex-wrap: wrap;' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'click': function(ev) {
|
||||||
|
ev.target.disabled = true;
|
||||||
|
ev.target.classList.add('spinning');
|
||||||
|
API.updateHub().then(function(result) {
|
||||||
|
ev.target.disabled = false;
|
||||||
|
ev.target.classList.remove('spinning');
|
||||||
|
if (result && result.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Hub index updated successfully')), 'info');
|
||||||
|
window.setTimeout(function() { location.reload(); }, 1500);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.error || _('Failed to update hub')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('⬇ Update Hub Index'))
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
|
||||||
|
// CrowdSec Console
|
||||||
|
E('div', { 'style': 'margin-top: 1.5em;' }, [
|
||||||
|
E('h4', { 'style': 'margin-bottom: 0.5em; color: var(--cyber-text-secondary, #888);' }, _('CrowdSec Console')),
|
||||||
|
E('div', { 'style': 'display: flex; gap: 0.5em; flex-wrap: wrap;' }, [
|
||||||
|
E('a', {
|
||||||
|
'href': 'https://app.crowdsec.net',
|
||||||
|
'target': '_blank',
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'style': 'text-decoration: none; display: inline-flex; align-items: center; gap: 0.5em;'
|
||||||
|
}, _('🌐 Open CrowdSec Console'))
|
||||||
|
])
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
|||||||
@ -1049,10 +1049,49 @@ console_disable() {
|
|||||||
json_dump
|
json_dump
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service control (start/stop/restart/reload)
|
||||||
|
service_control() {
|
||||||
|
local action="$1"
|
||||||
|
json_init
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
start|stop|restart|reload)
|
||||||
|
secubox_log "CrowdSec service $action requested"
|
||||||
|
local output
|
||||||
|
output=$(/etc/init.d/crowdsec "$action" 2>&1)
|
||||||
|
local result=$?
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Check if service is running after action
|
||||||
|
local running=0
|
||||||
|
if pgrep -x crowdsec >/dev/null 2>&1; then
|
||||||
|
running=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$result" -eq 0 ]; then
|
||||||
|
json_add_boolean "success" 1
|
||||||
|
json_add_string "action" "$action"
|
||||||
|
json_add_boolean "running" "$running"
|
||||||
|
json_add_string "message" "Service $action completed"
|
||||||
|
else
|
||||||
|
json_add_boolean "success" 0
|
||||||
|
json_add_string "error" "Service $action failed"
|
||||||
|
json_add_string "output" "$output"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
json_add_boolean "success" 0
|
||||||
|
json_add_string "error" "Invalid action. Use: start, stop, restart, reload"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
json_dump
|
||||||
|
}
|
||||||
|
|
||||||
# Main dispatcher
|
# Main dispatcher
|
||||||
case "$1" in
|
case "$1" in
|
||||||
list)
|
list)
|
||||||
echo '{"decisions":{},"alerts":{"limit":"number"},"metrics":{},"bouncers":{},"machines":{},"hub":{},"status":{},"ban":{"ip":"string","duration":"string","reason":"string"},"unban":{"ip":"string"},"stats":{},"seccubox_logs":{},"collect_debug":{},"waf_status":{},"metrics_config":{},"configure_metrics":{"enable":"string"},"collections":{},"install_collection":{"collection":"string"},"remove_collection":{"collection":"string"},"update_hub":{},"register_bouncer":{"bouncer_name":"string"},"delete_bouncer":{"bouncer_name":"string"},"firewall_bouncer_status":{},"control_firewall_bouncer":{"action":"string"},"firewall_bouncer_config":{},"update_firewall_bouncer_config":{"key":"string","value":"string"},"nftables_stats":{},"check_wizard_needed":{},"wizard_state":{},"repair_lapi":{},"console_status":{},"console_enroll":{"key":"string","name":"string"},"console_disable":{}}'
|
echo '{"decisions":{},"alerts":{"limit":"number"},"metrics":{},"bouncers":{},"machines":{},"hub":{},"status":{},"ban":{"ip":"string","duration":"string","reason":"string"},"unban":{"ip":"string"},"stats":{},"seccubox_logs":{},"collect_debug":{},"waf_status":{},"metrics_config":{},"configure_metrics":{"enable":"string"},"collections":{},"install_collection":{"collection":"string"},"remove_collection":{"collection":"string"},"update_hub":{},"register_bouncer":{"bouncer_name":"string"},"delete_bouncer":{"bouncer_name":"string"},"firewall_bouncer_status":{},"control_firewall_bouncer":{"action":"string"},"firewall_bouncer_config":{},"update_firewall_bouncer_config":{"key":"string","value":"string"},"nftables_stats":{},"check_wizard_needed":{},"wizard_state":{},"repair_lapi":{},"console_status":{},"console_enroll":{"key":"string","name":"string"},"console_disable":{},"service_control":{"action":"string"}}'
|
||||||
;;
|
;;
|
||||||
call)
|
call)
|
||||||
case "$2" in
|
case "$2" in
|
||||||
@ -1178,6 +1217,11 @@ case "$1" in
|
|||||||
console_disable)
|
console_disable)
|
||||||
console_disable
|
console_disable
|
||||||
;;
|
;;
|
||||||
|
service_control)
|
||||||
|
read -r input
|
||||||
|
action=$(echo "$input" | jsonfilter -e '@.action' 2>/dev/null)
|
||||||
|
service_control "$action"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo '{"error": "Unknown method"}'
|
echo '{"error": "Unknown method"}'
|
||||||
;;
|
;;
|
||||||
|
|||||||
@ -43,7 +43,8 @@
|
|||||||
"update_firewall_bouncer_config",
|
"update_firewall_bouncer_config",
|
||||||
"repair_lapi",
|
"repair_lapi",
|
||||||
"console_enroll",
|
"console_enroll",
|
||||||
"console_disable"
|
"console_disable",
|
||||||
|
"service_control"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"uci": [ "crowdsec-dashboard" ]
|
"uci": [ "crowdsec-dashboard" ]
|
||||||
|
|||||||
@ -232,6 +232,18 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sh-status-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-status-body strong {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--sh-text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
.sh-status-card.ok {
|
.sh-status-card.ok {
|
||||||
border-left: 3px solid #22c55e;
|
border-left: 3px solid #22c55e;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user