fix: terminal.astro must be SSR, not prerendered
Page reads from astro:content collections (machines, sessions, users) that are populated at request time from headscale. Pre-rendering at build time hits empty/undefined data and throws 'Cannot read properties of undefined (reading length)'.
This commit is contained in:
parent
72f6277b6f
commit
6e2679ac3a
553
src/pages/terminal.astro
Normal file
553
src/pages/terminal.astro
Normal file
@ -0,0 +1,553 @@
|
||||
---
|
||||
// Heady Terminal Page - Alpine.js/Astro Remote Access 🤠
|
||||
// SSR-only: the page reads from `astro:content` collections that are
|
||||
// populated at request time from headscale, so pre-rendering at build
|
||||
// time hits empty/undefined data and throws.
|
||||
export const prerender = false;
|
||||
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import GuacamoleLiteClient from '../components/GuacamoleLiteClient.astro';
|
||||
import NodeGrid from '../components/NodeGrid.astro';
|
||||
import SessionManager from '../components/SessionManager.astro';
|
||||
|
||||
// Fetch live data for terminal page with error handling
|
||||
let allMachines = [];
|
||||
let activeSessions = [];
|
||||
let currentUser = null;
|
||||
|
||||
try {
|
||||
allMachines = await getCollection('machines');
|
||||
activeSessions = await getCollection('sessions', ({ data }) => data.status === 'active');
|
||||
const users = await getCollection('users');
|
||||
currentUser = users[0] || null; // In real app, get from auth
|
||||
} catch (error) {
|
||||
console.error('Error loading terminal page data:', error);
|
||||
// Use fallback data for development
|
||||
}
|
||||
|
||||
// Filter online machines for terminal access
|
||||
const onlineMachines = allMachines.filter(machine => machine.data?.online);
|
||||
|
||||
// User permissions for protocol access
|
||||
const userPermissions = {
|
||||
ssh: true,
|
||||
rdp: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin' || currentUser?.data.role === 'it_admin',
|
||||
vnc: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin',
|
||||
telnet: currentUser?.data.role !== 'member' && currentUser?.data.role !== 'auditor',
|
||||
kubernetes: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin' || currentUser?.data.role === 'network_admin',
|
||||
file_transfer: currentUser?.data.role !== 'member' && currentUser?.data.role !== 'auditor',
|
||||
session_recording: currentUser?.data.role !== 'owner'
|
||||
};
|
||||
|
||||
// Available protocols based on permissions
|
||||
const availableProtocols = Object.entries(userPermissions)
|
||||
.filter(([protocol, allowed]) => allowed && ['ssh', 'rdp', 'vnc', 'telnet', 'kubernetes'].includes(protocol))
|
||||
.map(([protocol]) => protocol);
|
||||
---
|
||||
|
||||
<Layout title="Remote Access Terminal">
|
||||
<div
|
||||
class="min-h-screen bg-gray-900"
|
||||
x-data="terminalPage()"
|
||||
x-init="init()"
|
||||
>
|
||||
<!-- Terminal Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="mb-4 lg:mb-0">
|
||||
<h1 class="text-2xl font-bold text-white flex items-center">
|
||||
<span class="mr-3">💻</span>
|
||||
Heady Remote Access
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-400">
|
||||
Secure multi-protocol terminal access to your VPN infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Connection -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-400">Role:</span>
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded font-medium"
|
||||
:class="`heady-role-${currentUser?.role?.replace('_', '-')}`"
|
||||
x-text="currentUser?.role"
|
||||
>
|
||||
{currentUser?.data.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="showQuickConnect = true"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
🚀 Quick Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
x-model="searchTerm"
|
||||
type="text"
|
||||
placeholder="Search machines..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Filter -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{availableProtocols.map(protocol => (
|
||||
<button
|
||||
@click={`selectedProtocol = '${protocol}'`}
|
||||
:class={`selectedProtocol === '${protocol}' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'`}
|
||||
class="px-3 py-2 text-sm rounded font-semibold transition-colors"
|
||||
>
|
||||
{protocol.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="viewMode = 'grid'"
|
||||
:class="viewMode === 'grid' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
|
||||
class="px-3 py-2 text-sm rounded transition-colors"
|
||||
>
|
||||
🔲 Grid
|
||||
</button>
|
||||
<button
|
||||
@click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
|
||||
class="px-3 py-2 text-sm rounded transition-colors"
|
||||
>
|
||||
📋 List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Active Sessions Section -->
|
||||
<div x-show="activeSessions && activeSessions.length > 0" x-transition class="mb-8">
|
||||
<SessionManager
|
||||
sessions={activeSessions}
|
||||
userId={currentUser?.data.id}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Machines Grid/List -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center">
|
||||
<span class="mr-2">🖥️</span>
|
||||
Available Machines
|
||||
<span class="ml-2 text-sm text-gray-400" x-text="`(${filteredMachines ? filteredMachines.length : 0})`"></span>
|
||||
</h2>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-400">Sort by:</span>
|
||||
<select
|
||||
x-model="sortBy"
|
||||
@change="sortMachines()"
|
||||
class="bg-gray-800 border border-gray-600 text-white text-sm rounded px-3 py-1"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="last_seen">Last Seen</option>
|
||||
<option value="os">OS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div x-show="viewMode === 'grid'" x-transition>
|
||||
<NodeGrid
|
||||
nodes={onlineMachines}
|
||||
userId={currentUser?.data.id}
|
||||
showOffline={false}
|
||||
gridCols={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div x-show="viewMode === 'list'" x-transition>
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-800 border-b border-gray-700">
|
||||
<h3 class="text-lg font-medium text-white">Machine List</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-700">
|
||||
<template x-for="machine in filteredMachines" :key="machine.id">
|
||||
<div
|
||||
class="px-6 py-4 hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
@click="connectToMachine(machine)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Machine Info -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="machine.online ? 'bg-green-500 animate-pulse' : 'bg-gray-500'"
|
||||
></div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white" x-text="machine.name"></div>
|
||||
<div class="text-xs text-gray-400" x-text="machine.ip_address"></div>
|
||||
</div>
|
||||
|
||||
<!-- OS Icon -->
|
||||
<div class="text-lg">
|
||||
<span x-show="machine.os === 'linux'">🐧</span>
|
||||
<span x-show="machine.os === 'windows'">🪟</span>
|
||||
<span x-show="machine.os === 'macos'">🍎</span>
|
||||
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocols and Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Available Protocols -->
|
||||
<div class="flex gap-1">
|
||||
<template x-for="protocol in getAvailableProtocols(machine)" :key="protocol">
|
||||
<span
|
||||
class="heady-protocol-badge text-xs"
|
||||
:class="`protocol-${protocol}`"
|
||||
x-text="protocol"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click.stop="quickConnect(machine, 'ssh')"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-2 py-1 text-xs rounded transition-colors"
|
||||
>
|
||||
SSH
|
||||
</button>
|
||||
|
||||
<template x-if="machine.os === 'windows' && hasPermission('rdp')">
|
||||
<button
|
||||
@click.stop="quickConnect(machine, 'rdp')"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 text-xs rounded transition-colors"
|
||||
>
|
||||
RDP
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="filteredMachines && filteredMachines.length === 0" x-transition class="text-center py-12">
|
||||
<span class="text-6xl mb-4 block">🤠</span>
|
||||
<h3 class="text-xl font-semibold mb-2 text-white">No machines available</h3>
|
||||
<p class="text-gray-400">
|
||||
<span x-show="searchTerm">No machines match your search criteria.</span>
|
||||
<span x-show="!searchTerm">No online machines support the selected protocol.</span>
|
||||
</p>
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="mt-4 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Modal -->
|
||||
<div
|
||||
x-show="isTerminalOpen"
|
||||
x-transition
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
|
||||
@click.self="closeTerminal()"
|
||||
>
|
||||
<div class="w-full h-full md:w-5/6 md:h-5/6 md:max-w-6xl">
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<!-- Terminal Header -->
|
||||
<div class="bg-gray-800 border-b border-gray-600 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span
|
||||
class="heady-protocol-badge"
|
||||
:class="`protocol-${currentConnection?.protocol}`"
|
||||
x-text="currentConnection?.protocol?.toUpperCase()"
|
||||
></span>
|
||||
<span class="font-mono text-white" x-text="currentConnection?.node"></span>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="connectionStatus === 'connected' ? 'bg-green-500 animate-pulse' :
|
||||
connectionStatus === 'connecting' ? 'bg-yellow-500' :
|
||||
'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-xs text-gray-300" x-text="connectionStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-300" x-text="`${currentUser?.email} • ${currentUser?.role}`"></span>
|
||||
<button
|
||||
@click="closeTerminal()"
|
||||
class="text-red-400 hover:text-red-300 font-bold text-lg"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guacamole Client Container -->
|
||||
<div class="flex-1" x-show="currentConnection">
|
||||
<GuacamoleLiteClient
|
||||
x-bind:nodeId="currentConnection?.node"
|
||||
x-bind:protocol="currentConnection?.protocol"
|
||||
autoConnect={true}
|
||||
showToolbar={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Connect Modal -->
|
||||
<div
|
||||
x-show="showQuickConnect"
|
||||
x-transition
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="showQuickConnect = false"
|
||||
>
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Quick Connect</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Machine Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Machine</label>
|
||||
<select
|
||||
x-model="quickConnectMachine"
|
||||
class="w-full bg-gray-900 border border-gray-600 text-white rounded px-3 py-2"
|
||||
>
|
||||
<option value="">Select a machine...</option>
|
||||
<template x-for="machine in onlineMachines" :key="machine.id">
|
||||
<option :value="machine.name" x-text="`${machine.name} (${machine.ip_address})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Protocol</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{availableProtocols.map(protocol => (
|
||||
<button
|
||||
@click={`quickConnectProtocol = '${protocol}'`}
|
||||
:class={`quickConnectProtocol === '${protocol}' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'`}
|
||||
class="px-3 py-2 text-sm rounded font-medium transition-colors"
|
||||
>
|
||||
{protocol.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
@click="executeQuickConnect()"
|
||||
:disabled="!quickConnectMachine || !quickConnectProtocol"
|
||||
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-4 py-2 rounded font-medium transition-colors"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
<button
|
||||
@click="showQuickConnect = false"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ onlineMachines, activeSessions, userPermissions, currentUser }}>
|
||||
function terminalPage() {
|
||||
return {
|
||||
// Current user and permissions
|
||||
currentUser: currentUser?.data || null,
|
||||
permissions: userPermissions,
|
||||
|
||||
// UI State
|
||||
searchTerm: '',
|
||||
selectedProtocol: 'ssh',
|
||||
viewMode: 'grid',
|
||||
sortBy: 'name',
|
||||
|
||||
// Machine data
|
||||
machines: onlineMachines.map(m => m.data),
|
||||
activeSessions: activeSessions.map(s => s.data),
|
||||
|
||||
// Terminal state
|
||||
isTerminalOpen: false,
|
||||
currentConnection: null,
|
||||
connectionStatus: 'disconnected',
|
||||
|
||||
// Quick connect
|
||||
showQuickConnect: false,
|
||||
quickConnectMachine: '',
|
||||
quickConnectProtocol: 'ssh',
|
||||
|
||||
get filteredMachines() {
|
||||
let filtered = this.machines.filter(machine => {
|
||||
const matchesSearch = machine.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||
machine.ip_address.includes(this.searchTerm);
|
||||
const hasProtocol = this.getAvailableProtocols(machine).includes(this.selectedProtocol);
|
||||
return matchesSearch && hasProtocol;
|
||||
});
|
||||
|
||||
return this.sortMachines(filtered);
|
||||
},
|
||||
|
||||
get onlineMachines() {
|
||||
return this.machines.filter(m => m.online);
|
||||
},
|
||||
|
||||
init() {
|
||||
// Parse JSON data
|
||||
this.currentUser = JSON.parse(this.currentUser || 'null');
|
||||
this.permissions = JSON.parse(this.permissions);
|
||||
this.machines = JSON.parse(this.machines);
|
||||
this.activeSessions = JSON.parse(this.activeSessions);
|
||||
|
||||
// Set up periodic refresh
|
||||
setInterval(() => this.refreshData(), 30000);
|
||||
|
||||
// Listen for terminal events
|
||||
this.$el.addEventListener('open-terminal', (e) => {
|
||||
this.connectToNode(e.detail.node, e.detail.protocol);
|
||||
});
|
||||
},
|
||||
|
||||
getAvailableProtocols(machine) {
|
||||
const protocols = [];
|
||||
|
||||
if (machine.ssh_enabled && this.permissions.ssh) protocols.push('ssh');
|
||||
if (machine.rdp_enabled && this.permissions.rdp) protocols.push('rdp');
|
||||
if (machine.vnc_enabled && this.permissions.vnc) protocols.push('vnc');
|
||||
if (machine.telnet_enabled && this.permissions.telnet) protocols.push('telnet');
|
||||
if (machine.kubernetes_enabled && this.permissions.kubernetes) protocols.push('kubernetes');
|
||||
|
||||
return protocols;
|
||||
},
|
||||
|
||||
hasPermission(protocol) {
|
||||
return this.permissions[protocol] || false;
|
||||
},
|
||||
|
||||
sortMachines(machines = this.machines) {
|
||||
return machines.sort((a, b) => {
|
||||
switch (this.sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'status':
|
||||
return b.online - a.online;
|
||||
case 'last_seen':
|
||||
return new Date(b.last_seen || 0) - new Date(a.last_seen || 0);
|
||||
case 'os':
|
||||
return (a.os || 'unknown').localeCompare(b.os || 'unknown');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
connectToMachine(machine) {
|
||||
const protocols = this.getAvailableProtocols(machine);
|
||||
const protocol = protocols.includes(this.selectedProtocol) ? this.selectedProtocol : protocols[0];
|
||||
this.connectToNode(machine.name, protocol);
|
||||
},
|
||||
|
||||
connectToNode(nodeName, protocol) {
|
||||
this.currentConnection = { node: nodeName, protocol };
|
||||
this.connectionStatus = 'connecting';
|
||||
this.isTerminalOpen = true;
|
||||
},
|
||||
|
||||
quickConnect(machine, protocol) {
|
||||
this.connectToNode(machine.name, protocol);
|
||||
},
|
||||
|
||||
executeQuickConnect() {
|
||||
if (this.quickConnectMachine && this.quickConnectProtocol) {
|
||||
this.connectToNode(this.quickConnectMachine, this.quickConnectProtocol);
|
||||
this.showQuickConnect = false;
|
||||
this.quickConnectMachine = '';
|
||||
this.quickConnectProtocol = 'ssh';
|
||||
}
|
||||
},
|
||||
|
||||
closeTerminal() {
|
||||
this.isTerminalOpen = false;
|
||||
this.currentConnection = null;
|
||||
this.connectionStatus = 'disconnected';
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.searchTerm = '';
|
||||
this.selectedProtocol = 'ssh';
|
||||
},
|
||||
|
||||
async refreshData() {
|
||||
try {
|
||||
const [machinesResponse, sessionsResponse] = await Promise.all([
|
||||
fetch('/api/machines'),
|
||||
fetch('/api/sessions?status=active')
|
||||
]);
|
||||
|
||||
if (machinesResponse.ok) {
|
||||
this.machines = await machinesResponse.json();
|
||||
}
|
||||
|
||||
if (sessionsResponse.ok) {
|
||||
this.activeSessions = await sessionsResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh terminal data:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Make globally available
|
||||
window.terminalPage = terminalPage;
|
||||
</script>
|
||||
</Layout>
|
||||
Loading…
x
Reference in New Issue
Block a user