<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TShare - Terminal Session {{ session_id }}</title>
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<link href="https://unpkg.com/xterm@5.3.0/css/xterm.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
.terminal-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.glass-effect {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.1);
}
</style>
</head>
<body class="bg-gray-900 text-white font-sans">
<div id="auth-modal"
class="{{ modal_display }} fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 p-8 rounded-lg shadow-2xl max-w-md w-full mx-4">
<h2 class="text-2xl font-bold mb-6 text-center bg-gradient-to-r from-green-400 to-blue-500 bg-clip-text text-transparent">
Join Terminal Session
</h2>
<p class="text-gray-300 text-center mb-6">Session ID: {{ session_id }}</p>
<div class="space-y-4">
<input type="password"
id="password-input"
placeholder="Enter password (leave empty if none)"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded focus:border-blue-500 focus:outline-none">
<div id="auth-error" class="text-red-400 text-sm hidden"></div>
<button id="connect-btn"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded transition duration-200">
Connect
</button>
</div>
</div>
</div>
<div id="terminal-container"
class="{{ terminal_display }} min-h-screen bg-gray-900 text-white">
<div class="p-6">
<div class="mb-6 bg-gray-800 rounded-lg p-4 shadow-lg">
<div class="grid grid-cols-3 items-start">
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-green-400 to-blue-500 bg-clip-text text-transparent">
Terminal Session
</h1>
<p class="text-sm text-gray-400 mt-1">Session ID: {{ session_id }}</p>
<div class="flex items-center gap-3 mt-2">
<div class="text-xs px-2 py-1 rounded bg-blue-600 text-white"
id="user-type-badge">Guest</div>
<div class="text-xs px-2 py-1 rounded bg-green-600 text-white hidden"
id="readonly-badge">Read-only</div>
</div>
</div>
<div class="relative flex justify-center">
<button id="users-dropdown-btn"
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded-lg transition duration-200 flex items-center gap-2 text-sm">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
</svg>
<span id="users-count">Loading...</span>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div id="users-dropdown"
class="hidden absolute top-full mt-2 left-1/2 transform -translate-x-1/2 bg-gray-800 rounded-lg shadow-xl border border-gray-600 min-w-48 z-50">
<div class="p-2 border-b border-gray-600">
<div class="text-sm font-medium text-gray-300">Connected Users</div>
</div>
<div id="users-list" class="p-2">
<div class="text-sm text-gray-400">Loading...</div>
</div>
</div>
</div>
<div class="flex items-center gap-4 justify-end">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 rounded-full bg-red-400" id="status-indicator"></div>
<div id="status" class="text-sm font-medium text-red-400">Disconnected</div>
</div>
<button id="logout-btn"
class="hidden text-xs px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded transition duration-200">
Logout
</button>
</div>
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4 shadow-xl border border-gray-700">
<div id="terminal"
class="bg-black rounded border border-gray-600"
style="width: 100%;
height: calc(100vh - 280px);
min-height: 400px"></div>
</div>
</div>
</div>
<script>
const sessionId = '{{ session_id }}';
const needsAuth = {{ needs_auth_js }};
let term = null;
let ws = null;
let sessionEnded = false;
let historyReceived = false;
let userType = 'guest';
let isReadonly = false;
let usersRefreshInterval = null;
let usersCountInterval = null;
let currentUserId = null;
let heartbeatInterval = null;
const authModal = document.getElementById('auth-modal');
const terminalContainer = document.getElementById('terminal-container');
const passwordInput = document.getElementById('password-input');
const connectBtn = document.getElementById('connect-btn');
const authError = document.getElementById('auth-error');
const status = document.getElementById('status');
const statusIndicator = document.getElementById('status-indicator');
const userTypeBadge = document.getElementById('user-type-badge');
const readonlyBadge = document.getElementById('readonly-badge');
const usersDropdownBtn = document.getElementById('users-dropdown-btn');
const usersDropdown = document.getElementById('users-dropdown');
const usersCount = document.getElementById('users-count');
const usersList = document.getElementById('users-list');
const logoutBtn = document.getElementById('logout-btn');
async function authenticate() {
const password = passwordInput.value || '';
connectBtn.disabled = true;
const originalText = connectBtn.textContent;
connectBtn.innerHTML = `
<svg class="animate-spin h-4 w-4 text-white inline mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Connecting...
`;
try {
const response = await fetch(`/api/auth/${sessionId}?password=${encodeURIComponent(password)}`);
const result = await response.json();
if (result.authenticated) {
userType = result.user_type || 'guest';
isReadonly = result.is_readonly || false;
console.log('Authenticated as:', userType, 'readonly:', isReadonly);
updateStatusBadges();
showLogoutButton();
authModal.style.display = 'none';
terminalContainer.style.display = 'block';
initializeTerminal();
startUsersCountPolling();
} else {
authError.textContent = 'Invalid password';
authError.classList.remove('hidden');
}
} catch (e) {
authError.textContent = 'Authentication failed';
authError.classList.remove('hidden');
} finally {
connectBtn.disabled = false;
connectBtn.textContent = originalText;
}
}
function updateStatusBadges() {
userTypeBadge.textContent = userType.charAt(0).toUpperCase() + userType.slice(1);
if (userType === 'owner') {
userTypeBadge.className = 'text-xs px-2 py-1 rounded bg-purple-600 text-white';
} else {
userTypeBadge.className = 'text-xs px-2 py-1 rounded bg-blue-600 text-white';
}
if (isReadonly) {
readonlyBadge.classList.remove('hidden');
readonlyBadge.className = 'text-xs px-2 py-1 rounded bg-orange-600 text-white';
} else {
readonlyBadge.classList.add('hidden');
}
}
function showLogoutButton() {
logoutBtn.classList.remove('hidden');
}
function hideLogoutButton() {
logoutBtn.classList.add('hidden');
}
async function logout() {
try {
await fetch('/api/logout', { method: 'POST' });
userType = 'guest';
isReadonly = false;
hideLogoutButton();
stopUsersCountPolling();
cleanupTerminal(true);
if (needsAuth) {
terminalContainer.style.display = 'none';
authModal.style.display = 'flex';
passwordInput.value = '';
authError.classList.add('hidden');
}
if (ws) {
ws.close();
}
console.log('Logged out successfully');
} catch (e) {
console.error('Logout failed:', e);
}
}
async function fetchConnectedUsers() {
try {
const response = await fetch(`/api/session/${sessionId}/users`);
if (response.ok) {
const users = await response.json();
updateUsersDisplay(users);
} else {
console.error('Failed to fetch connected users');
usersCount.textContent = 'Error';
}
} catch (e) {
console.error('Error fetching connected users:', e);
usersCount.textContent = 'Error';
}
}
function startUsersCountPolling() {
usersCountInterval = setInterval(fetchConnectedUsers, 3000);
fetchConnectedUsers();
}
function stopUsersCountPolling() {
if (usersCountInterval) {
clearInterval(usersCountInterval);
usersCountInterval = null;
}
}
async function sendHeartbeat() {
if (currentUserId) {
try {
await fetch(`/api/session/${sessionId}/heartbeat/${currentUserId}`, {
method: 'POST'
});
} catch (e) {
console.error('Failed to send heartbeat:', e);
}
}
}
function startHeartbeat() {
heartbeatInterval = setInterval(sendHeartbeat, 5000);
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
function updateUsersDisplay(users) {
const ownerCount = users.filter(u => u.user_type === 'owner').length;
const guestCount = users.filter(u => u.user_type === 'guest').length;
const totalCount = users.length;
if (totalCount === 0) {
usersCount.textContent = 'No users connected';
} else if (totalCount === 1) {
usersCount.textContent = '1 user connected';
} else {
usersCount.textContent = `${totalCount} users connected`;
}
usersList.innerHTML = '';
if (users.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'text-sm text-gray-400';
emptyDiv.textContent = 'No users connected';
usersList.appendChild(emptyDiv);
} else {
if (ownerCount > 0) {
const ownerDiv = document.createElement('div');
ownerDiv.className = 'flex items-center gap-2 py-1 text-sm';
ownerDiv.innerHTML = `
<div class="w-2 h-2 rounded-full bg-purple-400"></div>
<span class="text-purple-300">Owner connected</span>
`;
usersList.appendChild(ownerDiv);
}
const guests = users.filter(u => u.user_type === 'guest');
const usedNumbers = new Set();
const guestNumbers = [];
guests.forEach((guest, index) => {
let number = 1;
while (usedNumbers.has(number)) {
number++;
}
usedNumbers.add(number);
guestNumbers.push(number);
});
guests.forEach((guest, index) => {
const guestDiv = document.createElement('div');
guestDiv.className = 'flex items-center gap-2 py-1 text-sm';
guestDiv.innerHTML = `
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
<span class="text-blue-300">Guest ${guestNumbers[index]}</span>
`;
usersList.appendChild(guestDiv);
});
}
}
function toggleUsersDropdown() {
usersDropdown.classList.toggle('hidden');
if (!usersDropdown.classList.contains('hidden')) {
fetchConnectedUsers();
usersRefreshInterval = setInterval(fetchConnectedUsers, 2000);
} else {
if (usersRefreshInterval) {
clearInterval(usersRefreshInterval);
usersRefreshInterval = null;
}
}
}
document.addEventListener('click', function(event) {
if (!usersDropdownBtn.contains(event.target) && !usersDropdown.contains(event.target)) {
usersDropdown.classList.add('hidden');
if (usersRefreshInterval) {
clearInterval(usersRefreshInterval);
usersRefreshInterval = null;
}
}
});
function cleanupTerminal(resetStatus = true) {
if (ws) {
ws.close();
ws = null;
}
if (term) {
term.dispose();
term = null;
}
const terminalElement = document.getElementById('terminal');
if (terminalElement) {
terminalElement.innerHTML = '';
}
if (usersRefreshInterval) {
clearInterval(usersRefreshInterval);
usersRefreshInterval = null;
}
stopHeartbeat();
sessionEnded = false;
historyReceived = false;
if (resetStatus) {
status.textContent = 'Disconnected';
status.className = 'text-sm font-medium text-red-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-red-400';
}
console.log('Terminal cleaned up');
}
function initializeTerminal() {
cleanupTerminal(false);
term = new Terminal({
theme: {
background: '#000000',
foreground: '#ffffff',
cursor: '#ffffff',
selection: '#ffffff33'
},
fontFamily: 'Monaco, "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, Courier, monospace',
fontSize: 14,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
allowTransparency: false
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const terminalElement = document.getElementById('terminal');
term.open(terminalElement);
setTimeout(() => {
fitAddon.fit();
sendTerminalSize();
term.focus();
}, 100);
window.addEventListener('resize', () => {
setTimeout(() => {
fitAddon.fit();
sendTerminalSize();
}, 100);
});
term.onResize((size) => {
sendTerminalSize();
});
connectWebSocket();
}
function sendTerminalSize() {
if (ws && ws.readyState === WebSocket.OPEN) {
const cols = term.cols;
const rows = term.rows;
const resizeMsg = JSON.stringify({
type: 'resize',
cols: cols,
rows: rows
});
ws.send(resizeMsg);
console.log(`Sent terminal resize: ${cols}x${rows}`);
}
}
function connectWebSocket() {
status.textContent = 'Connecting...';
status.className = 'text-sm font-medium text-yellow-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-yellow-400';
const wsUrl = `ws://${window.location.host}/ws/session/${sessionId}?user_type=${userType}`;
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
status.textContent = 'Connected';
status.className = 'text-sm font-medium text-green-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-green-400';
setTimeout(() => sendTerminalSize(), 200);
term.onData(function(data) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
};
ws.onmessage = function(event) {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data);
if (message.type === 'user_id') {
currentUserId = message.user_id;
console.log('Received user ID:', currentUserId);
startHeartbeat();
return;
}
} catch (e) {
if (!historyReceived) {
term.clear();
historyReceived = true;
console.log('Received terminal history, cleared terminal and writing history');
}
term.write(event.data);
}
} else if (event.data instanceof Blob) {
event.data.arrayBuffer().then(function(buffer) {
const text = new TextDecoder().decode(buffer);
if (text === '__TSHARE_SESSION_ENDED__') {
console.log('Session ended signal received');
if (!sessionEnded) {
sessionEnded = true;
status.textContent = 'Session ended';
status.className = 'text-sm font-medium text-red-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-red-400';
term.write('\r\n\x1b[31mTerminal session has ended.\x1b[0m\r\n');
}
ws.close(1000, 'Session ended');
return;
}
if (!historyReceived) {
term.clear();
historyReceived = true;
console.log('Received terminal history, cleared terminal and writing history');
}
term.write(text);
});
}
};
ws.onclose = function(event) {
console.log('WebSocket disconnected:', event.code, event.reason);
if (!sessionEnded) {
status.textContent = event.code === 1000 ? 'Session ended' : 'Disconnected';
status.className = 'text-sm font-medium text-red-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-red-400';
if (event.code === 1000) {
term.write('\r\n\x1b[31mTerminal session has ended.\x1b[0m\r\n');
}
}
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
status.textContent = 'Error';
status.className = 'text-sm font-medium text-red-400';
statusIndicator.className = 'w-2 h-2 rounded-full bg-red-400';
};
}
connectBtn.addEventListener('click', authenticate);
passwordInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
authenticate();
}
});
usersDropdownBtn.addEventListener('click', toggleUsersDropdown);
logoutBtn.addEventListener('click', logout);
window.addEventListener('beforeunload', function() {
cleanupTerminal(true);
stopUsersCountPolling();
stopHeartbeat();
});
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stopUsersCountPolling();
} else {
if (term && !sessionEnded) {
startUsersCountPolling();
}
}
});
if (!needsAuth) {
userType = 'owner'; isReadonly = false; updateStatusBadges();
showLogoutButton();
authModal.style.display = 'none';
terminalContainer.style.display = 'block';
initializeTerminal();
startUsersCountPolling();
}
</script>
</body>
</html>