version display on instances page
This commit is contained in:
Binary file not shown.
@@ -712,6 +712,101 @@ def init_routes(main_bp):
|
|||||||
'deployed_branch': instance.deployed_branch
|
'deployed_branch': instance.deployed_branch
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/api/latest-version')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def get_latest_version():
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get Git settings
|
||||||
|
git_settings = KeyValueSettings.get_value('git_settings')
|
||||||
|
if not git_settings:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Git settings not configured',
|
||||||
|
'latest_version': 'unknown',
|
||||||
|
'latest_commit': 'unknown',
|
||||||
|
'last_checked': None
|
||||||
|
})
|
||||||
|
|
||||||
|
latest_tag = None
|
||||||
|
latest_commit = None
|
||||||
|
|
||||||
|
if git_settings['provider'] == 'gitea':
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': f'token {git_settings["token"]}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest tag
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags',
|
||||||
|
headers=headers,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
# Sort tags by commit date (newest first) and get the latest
|
||||||
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||||
|
if sorted_tags:
|
||||||
|
latest_tag = sorted_tags[0].get('name')
|
||||||
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||||
|
else:
|
||||||
|
# Try token as query parameter if header auth fails
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags?token={git_settings["token"]}',
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||||
|
if sorted_tags:
|
||||||
|
latest_tag = sorted_tags[0].get('name')
|
||||||
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||||
|
|
||||||
|
elif git_settings['provider'] == 'gitlab':
|
||||||
|
headers = {
|
||||||
|
'PRIVATE-TOKEN': git_settings['token'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest tag
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v4/projects/{git_settings["repo"].replace("/", "%2F")}/repository/tags',
|
||||||
|
headers=headers,
|
||||||
|
params={'order_by': 'version', 'sort': 'desc', 'per_page': 1},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
latest_tag = tags_data[0].get('name')
|
||||||
|
latest_commit = tags_data[0].get('commit', {}).get('id')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'latest_version': latest_tag or 'unknown',
|
||||||
|
'latest_commit': latest_commit or 'unknown',
|
||||||
|
'repository': git_settings.get('repo', 'unknown'),
|
||||||
|
'provider': git_settings.get('provider', 'unknown'),
|
||||||
|
'last_checked': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Error fetching latest version: {str(e)}',
|
||||||
|
'latest_version': 'unknown',
|
||||||
|
'latest_commit': 'unknown',
|
||||||
|
'last_checked': datetime.utcnow().isoformat()
|
||||||
|
}), 500
|
||||||
|
|
||||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
||||||
if not os.path.exists(UPLOAD_FOLDER):
|
if not os.path.exists(UPLOAD_FOLDER):
|
||||||
os.makedirs(UPLOAD_FOLDER)
|
os.makedirs(UPLOAD_FOLDER)
|
||||||
|
|||||||
@@ -45,27 +45,57 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header(
|
{{ header(
|
||||||
title="Instances",
|
title="Instance Management",
|
||||||
description="Manage your DocuPulse instances",
|
description="Manage and monitor your DocuPulse instances",
|
||||||
icon="fa-server",
|
icon="fa-server",
|
||||||
buttons=[
|
buttons=[
|
||||||
{
|
{
|
||||||
'text': 'Launch New Instance',
|
'text': 'Launch New Instance',
|
||||||
'url': '#',
|
'onclick': 'showAddInstanceModal()',
|
||||||
'icon': 'fa-rocket',
|
'icon': 'fa-plus',
|
||||||
'class': 'btn-primary',
|
'class': 'btn-primary'
|
||||||
'onclick': 'showAddInstanceModal()'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'text': 'Add Existing Instance',
|
|
||||||
'url': '#',
|
|
||||||
'icon': 'fa-link',
|
|
||||||
'class': 'btn-primary',
|
|
||||||
'onclick': 'showAddExistingInstanceModal()'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
|
<!-- Latest Version Information -->
|
||||||
|
<div class="container-fluid mb-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h5 class="card-title mb-2">
|
||||||
|
<i class="fas fa-code-branch me-2" style="color: var(--primary-color);"></i>
|
||||||
|
Latest Available Version
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="me-4">
|
||||||
|
<span class="badge bg-success fs-6" id="latestVersionBadge">
|
||||||
|
<i class="fas fa-spinner fa-spin me-1"></i> Loading...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-clock me-1"></i>
|
||||||
|
Last checked: <span id="lastChecked" class="text-muted">Loading...</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="refreshLatestVersion()">
|
||||||
|
<i class="fas fa-sync-alt me-1"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -84,7 +114,6 @@
|
|||||||
<th>Main URL</th>
|
<th>Main URL</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th>Connection Token</th>
|
<th>Connection Token</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -126,16 +155,6 @@
|
|||||||
<span class="badge bg-secondary version-badge">unknown</span>
|
<span class="badge bg-secondary version-badge">unknown</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{% if instance.deployed_branch %}
|
|
||||||
<span class="badge bg-light text-dark branch-badge" data-bs-toggle="tooltip"
|
|
||||||
title="Deployed branch: {{ instance.deployed_branch }}">
|
|
||||||
{{ instance.deployed_branch }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary branch-badge">unknown</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if instance.connection_token %}
|
{% if instance.connection_token %}
|
||||||
<span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated">
|
<span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated">
|
||||||
@@ -729,10 +748,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Fetch company names for all instances
|
// Fetch company names for all instances
|
||||||
fetchCompanyNames();
|
fetchCompanyNames();
|
||||||
|
|
||||||
|
// Fetch latest version information
|
||||||
|
fetchLatestVersion();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Set up periodic status checks (every 30 seconds)
|
// Set up periodic status checks (every 30 seconds)
|
||||||
setInterval(checkAllInstanceStatuses, 30000);
|
setInterval(checkAllInstanceStatuses, 30000);
|
||||||
|
|
||||||
|
// Set up periodic latest version checks (every 5 minutes)
|
||||||
|
setInterval(fetchLatestVersion, 300000);
|
||||||
|
|
||||||
// Update color picker functionality
|
// Update color picker functionality
|
||||||
const primaryColor = document.getElementById('primaryColor');
|
const primaryColor = document.getElementById('primaryColor');
|
||||||
@@ -914,16 +939,12 @@ async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
|
|||||||
// Function to fetch version information for an instance
|
// Function to fetch version information for an instance
|
||||||
async function fetchVersionInfo(instanceUrl, instanceId) {
|
async function fetchVersionInfo(instanceUrl, instanceId) {
|
||||||
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
||||||
const versionCell = row.querySelector('td:nth-child(9)'); // Version column
|
const versionCell = row.querySelector('td:nth-child(9)'); // Version column (adjusted after removing branch)
|
||||||
const branchCell = row.querySelector('td:nth-child(10)'); // Branch column
|
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
if (versionCell) {
|
if (versionCell) {
|
||||||
versionCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
versionCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||||
}
|
}
|
||||||
if (branchCell) {
|
|
||||||
branchCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = document.querySelector(`[data-instance-id="${instanceId}"]`).dataset.token;
|
const apiKey = document.querySelector(`[data-instance-id="${instanceId}"]`).dataset.token;
|
||||||
@@ -961,41 +982,41 @@ async function fetchVersionInfo(instanceUrl, instanceId) {
|
|||||||
const deployedAt = data.deployed_at || 'unknown';
|
const deployedAt = data.deployed_at || 'unknown';
|
||||||
|
|
||||||
if (appVersion !== 'unknown') {
|
if (appVersion !== 'unknown') {
|
||||||
|
// Get the latest version for comparison
|
||||||
|
const latestVersionBadge = document.getElementById('latestVersionBadge');
|
||||||
|
const latestVersion = latestVersionBadge ? latestVersionBadge.textContent.replace('Loading...', '').trim() : null;
|
||||||
|
|
||||||
|
// Determine if this instance is up to date
|
||||||
|
let badgeClass = 'bg-secondary';
|
||||||
|
let statusIcon = 'fas fa-tag';
|
||||||
|
let tooltipText = `App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}`;
|
||||||
|
|
||||||
|
if (latestVersion && appVersion === latestVersion) {
|
||||||
|
badgeClass = 'bg-success';
|
||||||
|
statusIcon = 'fas fa-check-circle';
|
||||||
|
tooltipText += '<br><strong>✅ Up to date</strong>';
|
||||||
|
} else if (latestVersion && appVersion !== latestVersion) {
|
||||||
|
badgeClass = 'bg-danger';
|
||||||
|
statusIcon = 'fas fa-exclamation-triangle';
|
||||||
|
tooltipText += `<br><strong>⚠️ Outdated (Latest: ${latestVersion})</strong>`;
|
||||||
|
}
|
||||||
|
|
||||||
versionCell.innerHTML = `
|
versionCell.innerHTML = `
|
||||||
<span class="badge bg-info version-badge" data-bs-toggle="tooltip"
|
<span class="badge ${badgeClass} version-badge" data-bs-toggle="tooltip"
|
||||||
title="App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}">
|
title="${tooltipText}">
|
||||||
${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
|
<i class="${statusIcon} me-1"></i>${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
|
||||||
</span>`;
|
</span>`;
|
||||||
} else {
|
} else {
|
||||||
versionCell.innerHTML = '<span class="badge bg-secondary version-badge">unknown</span>';
|
versionCell.innerHTML = '<span class="badge bg-secondary version-badge">unknown</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update branch cell
|
|
||||||
if (branchCell) {
|
|
||||||
const gitBranch = data.git_branch || 'unknown';
|
|
||||||
|
|
||||||
if (gitBranch !== 'unknown') {
|
|
||||||
branchCell.innerHTML = `
|
|
||||||
<span class="badge bg-light text-dark branch-badge" data-bs-toggle="tooltip"
|
|
||||||
title="Deployed branch: ${gitBranch}">
|
|
||||||
${gitBranch}
|
|
||||||
</span>`;
|
|
||||||
} else {
|
|
||||||
branchCell.innerHTML = '<span class="badge bg-secondary branch-badge">unknown</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update tooltips
|
// Update tooltips
|
||||||
const versionBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
|
const versionBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
|
||||||
const branchBadge = branchCell?.querySelector('[data-bs-toggle="tooltip"]');
|
|
||||||
|
|
||||||
if (versionBadge) {
|
if (versionBadge) {
|
||||||
new bootstrap.Tooltip(versionBadge);
|
new bootstrap.Tooltip(versionBadge);
|
||||||
}
|
}
|
||||||
if (branchBadge) {
|
|
||||||
new bootstrap.Tooltip(branchBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching version info for instance ${instanceId}:`, error);
|
console.error(`Error fetching version info for instance ${instanceId}:`, error);
|
||||||
@@ -1007,16 +1028,12 @@ async function fetchVersionInfo(instanceUrl, instanceId) {
|
|||||||
<i class="fas fa-exclamation-triangle"></i> Error
|
<i class="fas fa-exclamation-triangle"></i> Error
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
if (branchCell) {
|
|
||||||
branchCell.innerHTML = `
|
|
||||||
<span class="text-warning" data-bs-toggle="tooltip" title="Error: ${error.message}">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i> Error
|
|
||||||
</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tooltips for error states
|
// Add tooltips for error states
|
||||||
const errorBadges = [versionCell, branchCell].map(cell => cell?.querySelector('[data-bs-toggle="tooltip"]')).filter(Boolean);
|
const errorBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
|
||||||
errorBadges.forEach(badge => new bootstrap.Tooltip(badge));
|
if (errorBadge) {
|
||||||
|
new bootstrap.Tooltip(errorBadge);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,8 +1147,7 @@ async function fetchCompanyNames() {
|
|||||||
row.querySelector('td:nth-child(3)'), // Rooms
|
row.querySelector('td:nth-child(3)'), // Rooms
|
||||||
row.querySelector('td:nth-child(4)'), // Conversations
|
row.querySelector('td:nth-child(4)'), // Conversations
|
||||||
row.querySelector('td:nth-child(5)'), // Data
|
row.querySelector('td:nth-child(5)'), // Data
|
||||||
row.querySelector('td:nth-child(9)'), // Version
|
row.querySelector('td:nth-child(9)') // Version
|
||||||
row.querySelector('td:nth-child(10)') // Branch
|
|
||||||
];
|
];
|
||||||
|
|
||||||
cells.forEach(cell => {
|
cells.forEach(cell => {
|
||||||
@@ -2019,5 +2035,109 @@ async function refreshAllVersionInfo() {
|
|||||||
console.error('Error refreshing version information:', error);
|
console.error('Error refreshing version information:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to fetch latest version information
|
||||||
|
async function fetchLatestVersion() {
|
||||||
|
console.log('Fetching latest version information...');
|
||||||
|
|
||||||
|
const versionBadge = document.getElementById('latestVersionBadge');
|
||||||
|
const commitSpan = document.getElementById('latestCommit');
|
||||||
|
const checkedSpan = document.getElementById('lastChecked');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (versionBadge) {
|
||||||
|
versionBadge.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Loading...';
|
||||||
|
versionBadge.className = 'badge bg-secondary fs-6';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/latest-version', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`HTTP error ${response.status}:`, errorText);
|
||||||
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Received latest version data:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Update version badge
|
||||||
|
if (versionBadge) {
|
||||||
|
const version = data.latest_version;
|
||||||
|
if (version !== 'unknown') {
|
||||||
|
versionBadge.innerHTML = `<i class="fas fa-tag me-1"></i>${version}`;
|
||||||
|
versionBadge.className = 'badge bg-success fs-6';
|
||||||
|
} else {
|
||||||
|
versionBadge.innerHTML = '<i class="fas fa-question-circle me-1"></i>Unknown';
|
||||||
|
versionBadge.className = 'badge bg-warning fs-6';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update commit information
|
||||||
|
if (commitSpan) {
|
||||||
|
const commit = data.latest_commit;
|
||||||
|
if (commit !== 'unknown') {
|
||||||
|
commitSpan.textContent = commit.substring(0, 8);
|
||||||
|
commitSpan.title = commit;
|
||||||
|
} else {
|
||||||
|
commitSpan.textContent = 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last checked time
|
||||||
|
if (checkedSpan) {
|
||||||
|
const lastChecked = data.last_checked;
|
||||||
|
if (lastChecked) {
|
||||||
|
const date = new Date(lastChecked);
|
||||||
|
checkedSpan.textContent = date.toLocaleString();
|
||||||
|
} else {
|
||||||
|
checkedSpan.textContent = 'Never';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle error response
|
||||||
|
if (versionBadge) {
|
||||||
|
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
|
||||||
|
versionBadge.className = 'badge bg-danger fs-6';
|
||||||
|
}
|
||||||
|
if (commitSpan) {
|
||||||
|
commitSpan.textContent = 'Error';
|
||||||
|
}
|
||||||
|
if (checkedSpan) {
|
||||||
|
checkedSpan.textContent = 'Error';
|
||||||
|
}
|
||||||
|
console.error('Error in latest version response:', data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching latest version:', error);
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
if (versionBadge) {
|
||||||
|
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
|
||||||
|
versionBadge.className = 'badge bg-danger fs-6';
|
||||||
|
}
|
||||||
|
if (commitSpan) {
|
||||||
|
commitSpan.textContent = 'Error';
|
||||||
|
}
|
||||||
|
if (checkedSpan) {
|
||||||
|
checkedSpan.textContent = 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to refresh latest version (called by button)
|
||||||
|
async function refreshLatestVersion() {
|
||||||
|
console.log('Manual refresh of latest version requested');
|
||||||
|
await fetchLatestVersion();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user