<?php
// Force login + disable caching
session_start();
require_once __DIR__ . '/../core/db_config.php';
// Anti-cache headers
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: 0');
// Redirect if not logged in
if (empty($_SESSION['username'])) {
$redirect = urlencode($_SERVER['REQUEST_URI'] ?? '/');
header("Location: /core/auth/login.php?redirect={$redirect}");
exit;
}
// Handle SFTP API requests
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['sftp_action'])) {
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ob_start();
// Clear any stale connections on new request
if (!isset($_SESSION['sftp_last_activity'])) {
unset($_SESSION['sftp_connected']);
unset($_SESSION['sftp_config']);
} elseif (time() - $_SESSION['sftp_last_activity'] > 3600) {
unset($_SESSION['sftp_connected']);
unset($_SESSION['sftp_config']);
}
class SFTPConnector {
private $connection;
private $sftp;
public function __construct() {
if (!extension_loaded('ssh2')) {
throw new Exception('SSH2 extension is not installed');
}
}
public function connect($host, $port, $username, $password) {
try {
$this->connection = ssh2_connect($host, $port);
if (!$this->connection) {
throw new Exception("Could not connect to $host on port $port");
}
if (!ssh2_auth_password($this->connection, $username, $password)) {
throw new Exception("Authentication failed for user $username");
}
$this->sftp = ssh2_sftp($this->connection);
if (!$this->sftp) {
throw new Exception("Could not initialize SFTP subsystem");
}
return true;
} catch (Exception $e) {
return $e->getMessage();
}
}
public function listDirectory($path = '.') {
try {
$handle = opendir("ssh2.sftp://{$this->sftp}$path");
if (!$handle) {
throw new Exception("Could not open directory: $path");
}
$files = [];
while (($file = readdir($handle)) !== false) {
if ($file !== '.' && $file !== '..') {
$fullPath = rtrim($path, '/') . '/' . $file;
$stat = ssh2_sftp_stat($this->sftp, $fullPath);
if ($stat !== false) {
$files[] = [
'name' => $file,
'path' => $fullPath,
'size' => $stat['size'],
'modified' => date('Y-m-d H:i:s', $stat['mtime']),
'type' => is_dir("ssh2.sftp://{$this->sftp}$fullPath") ? 'directory' : 'file'
];
}
}
}
closedir($handle);
usort($files, function($a, $b) {
if ($a['type'] === 'directory' && $b['type'] === 'file') return -1;
if ($a['type'] === 'file' && $b['type'] === 'directory') return 1;
return strcasecmp($a['name'], $b['name']);
});
return $files;
} catch (Exception $e) {
return false;
}
}
public function deleteFile($remotePath) {
try {
if (ssh2_sftp_unlink($this->sftp, $remotePath)) {
return true;
}
return "Failed to delete file";
} catch (Exception $e) {
return $e->getMessage();
}
}
public function disconnect() {
$this->connection = null;
$this->sftp = null;
}
}
class SystemSFTPConnector {
private $host;
private $port;
private $username;
private $password;
private $connected = false;
public function connect($host, $port, $username, $password) {
$this->host = $host;
$this->port = $port;
$this->username = $username;
$this->password = $password;
if (!function_exists('exec')) {
return 'System commands are disabled on this server';
}
$testCommand = "echo 'quit' | sftp -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -P {$port} {$username}@{$host} 2>&1";
$output = [];
$returnCode = null;
exec($testCommand, $output, $returnCode);
if ($returnCode === 0 || strpos(implode(' ', $output), 'Connected to') !== false) {
$this->connected = true;
return true;
}
return 'Cannot connect to SFTP server: ' . implode(' ', $output);
}
public function listDirectory($path = '/') {
if (!$this->connected) {
return false;
}
$scriptFile = tempnam(sys_get_temp_dir(), 'sftp_script');
$script = "cd {$path}\nls -la\nquit\n";
file_put_contents($scriptFile, $script);
$command = "sftp -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -P {$this->port} -b {$scriptFile} {$this->username}@{$this->host} 2>&1";
$output = [];
exec($command, $output);
unlink($scriptFile);
$files = [];
$inListing = false;
foreach ($output as $line) {
if (strpos($line, 'sftp>') !== false && strpos($line, 'ls') !== false) {
$inListing = true;
continue;
}
if ($inListing && strpos($line, 'sftp>') !== false) {
break;
}
if ($inListing && preg_match('/^([drwx-]+)\s+\d+\s+\w+\s+\w+\s+(\d+)\s+(\w+\s+\d+\s+[\d:]+)\s+(.+)$/', $line, $matches)) {
$name = $matches[4];
if ($name !== '.' && $name !== '..') {
$files[] = [
'name' => $name,
'path' => rtrim($path, '/') . '/' . $name,
'size' => (int)$matches[2],
'modified' => $matches[3],
'type' => substr($matches[1], 0, 1) === 'd' ? 'directory' : 'file'
];
}
}
}
return $files;
}
public function deleteFile($remotePath) {
if (!$this->connected) {
return 'Not connected';
}
$scriptFile = tempnam(sys_get_temp_dir(), 'sftp_script');
$script = "rm {$remotePath}\nquit\n";
file_put_contents($scriptFile, $script);
$command = "sftp -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -P {$this->port} -b {$scriptFile} {$this->username}@{$this->host} 2>&1";
$output = [];
exec($command, $output);
unlink($scriptFile);
if (strpos(implode(' ', $output), 'Removing') !== false || empty(array_filter($output))) {
return true;
}
return 'Delete failed: ' . implode(' ', $output);
}
public function disconnect() {
$this->connected = false;
}
}
$action = $_POST['sftp_action'] ?? '';
$response = ['success' => false, 'message' => '', 'data' => null];
try {
if (extension_loaded('ssh2')) {
$connector = new SFTPConnector();
$connectorType = 'SSH2 Extension';
} else if (function_exists('exec')) {
$connector = new SystemSFTPConnector();
$connectorType = 'System Commands';
} else {
throw new Exception('Neither SSH2 extension nor system commands are available');
}
switch ($action) {
case 'connect':
$result = $connector->connect(
$_POST['host'] ?? '',
$_POST['port'] ?? 22,
$_POST['username'] ?? '',
$_POST['password'] ?? ''
);
if ($result === true) {
$_SESSION['sftp_connected'] = true;
$_SESSION['sftp_config'] = [
'host' => $_POST['host'],
'port' => $_POST['port'] ?? 22,
'username' => $_POST['username'],
'password' => $_POST['password']
];
$_SESSION['sftp_last_activity'] = time();
$response['success'] = true;
$response['message'] = "Connected successfully via {$connectorType}";
} else {
$response['message'] = $result;
}
break;
case 'list':
if (!isset($_SESSION['sftp_connected']) || !$_SESSION['sftp_connected']) {
$response['success'] = false;
$response['message'] = 'Not connected to SFTP server. Please connect first.';
break;
}
$config = $_SESSION['sftp_config'];
if (extension_loaded('ssh2')) {
$connector = new SFTPConnector();
} else {
$connector = new SystemSFTPConnector();
}
$connectResult = $connector->connect(
$config['host'],
$config['port'],
$config['username'],
$config['password']
);
if ($connectResult !== true) {
unset($_SESSION['sftp_connected']);
unset($_SESSION['sftp_config']);
$response['message'] = 'Session expired. Please reconnect.';
break;
}
$_SESSION['sftp_last_activity'] = time();
$files = $connector->listDirectory($_POST['path'] ?? '/');
if ($files !== false) {
$response['success'] = true;
$response['data'] = $files;
} else {
$response['message'] = 'Failed to list directory';
}
break;
case 'delete':
if (!isset($_SESSION['sftp_connected']) || !$_SESSION['sftp_connected']) {
$response['message'] = 'Not connected to SFTP server';
break;
}
$config = $_SESSION['sftp_config'];
if (extension_loaded('ssh2')) {
$connector = new SFTPConnector();
} else {
$connector = new SystemSFTPConnector();
}
$connectResult = $connector->connect(
$config['host'],
$config['port'],
$config['username'],
$config['password']
);
if ($connectResult !== true) {
unset($_SESSION['sftp_connected']);
unset($_SESSION['sftp_config']);
$response['message'] = 'Session expired. Please reconnect.';
break;
}
$_SESSION['sftp_last_activity'] = time();
$result = $connector->deleteFile($_POST['path'] ?? '');
if ($result === true) {
$response['success'] = true;
$response['message'] = 'File deleted successfully';
} else {
$response['message'] = $result;
}
break;
case 'disconnect':
unset($_SESSION['sftp_connected']);
unset($_SESSION['sftp_config']);
unset($_SESSION['sftp_last_activity']);
if (isset($connector)) {
$connector->disconnect();
}
$response['success'] = true;
$response['message'] = 'Disconnected successfully';
break;
case 'status':
$response['success'] = true;
$response['data'] = [
'connected' => isset($_SESSION['sftp_connected']) && $_SESSION['sftp_connected'],
'config' => isset($_SESSION['sftp_config']) ? [
'host' => $_SESSION['sftp_config']['host'],
'username' => $_SESSION['sftp_config']['username'],
'port' => $_SESSION['sftp_config']['port']
] : null,
'last_activity' => $_SESSION['sftp_last_activity'] ?? null
];
break;
default:
$response['message'] = 'Invalid action: ' . $action;
}
} catch (Exception $e) {
$response['message'] = 'Server error: ' . $e->getMessage();
}
ob_clean();
echo json_encode($response);
exit;
}
// Cache-busting helper
function asset($path) {
$isAbsolute = strlen($path) && $path[0] === '/';
$abs = $isAbsolute ? rtrim($_SERVER['DOCUMENT_ROOT'], '/') . $path : __DIR__ . '/' . $path;
$v = is_file($abs) ? filemtime($abs) : time();
return $path . '?v=' . $v;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SFTP Manager</title>
<!-- Shared overlay CSS -->
<link rel="stylesheet" href="<?= asset('/core/css/overlay.css') ?>">
<style>
html, body { height: 100%; margin: 0; overscroll-behavior-y: contain; }
body {
background: #0b0f14;
color: #e6edf3;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, "Helvetica Neue", Arial;
}
.body-lock { overflow: hidden !important; touch-action: none; }
.topbar {
position: sticky; top: 0; z-index: 5;
background: linear-gradient(180deg, rgba(11,15,20,.95), rgba(11,15,20,.85));
border-bottom: 1px solid #1e2633;
backdrop-filter: blur(6px);
}
.topbar-inner {
max-width: 1400px;
margin: 0 auto;
padding: .75rem;
display: flex;
align-items: center;
gap: .75rem;
}
#buttonRow {
flex: 1 1 auto;
min-width: 0;
display: flex;
gap: .75rem;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
#menuContainer { flex: 0 0 auto; margin-left: .25rem; position: relative; }
.chip {
flex: 0 0 auto;
border: 1px solid #2a3648;
background: #1a2332;
color: #e6edf3;
padding: .55rem .9rem;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
transition: background .15s ease;
}
.chip:hover { background: #263244; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 1.25rem .75rem;
}
.menu-trigger { width: 38px; text-align: center; }
.menu-list {
display: none;
position: absolute;
right: 0; top: calc(100% + 6px);
background: #1a2332;
border: 1px solid #2a3648;
border-radius: 10px;
min-width: 180px;
padding: .25rem 0;
z-index: 9999;
box-shadow: 0 10px 30px rgba(0,0,0,.3);
}
.menu-list.open { display: block; }
.menu-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: #e6edf3;
padding: .6rem 1rem;
cursor: pointer;
font: inherit;
}
.menu-item:hover { background: #263244; }
/* Quick Connect Card - matches connection card style */
.quick-connect-card {
position: relative;
background: rgba(30, 41, 59, 0.7);
border: 2px solid #2a3648;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.quick-connect-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(59, 130, 246, 0.2);
}
.quick-connect-card.active {
border-color: #10b981;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.05));
}
.quick-status-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
border-radius: 12px 12px 0 0;
background: #2a3648;
}
.quick-connect-card.active .quick-status-bar {
background: linear-gradient(90deg, #10b981, #34d399);
}
.quick-connect-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-top: 8px;
margin-bottom: 12px;
}
.quick-connect-title {
font-size: 18px;
font-weight: 700;
color: #e6edf3;
display: flex;
align-items: center;
gap: 0.5rem;
}
.quick-badge {
display: inline-block;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-weight: 600;
}
.quick-connect-info {
font-size: 14px;
color: #9aa4b2;
line-height: 1.6;
margin-bottom: 12px;
}
.quick-info-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.quick-info-label {
font-weight: 600;
min-width: 60px;
}
.quick-password-input {
width: 100%;
padding: 0.625rem 0.75rem;
background: #0f1725;
border: 1px solid #2a3648;
border-radius: 8px;
color: #e6edf3;
font-size: 0.875rem;
margin-bottom: 8px;
transition: border-color 0.15s;
}
.quick-password-input:focus {
outline: none;
border-color: #3b82f6;
}
.quick-connect-btn {
width: 100%;
padding: 0.625rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.quick-connect-btn:hover {
background: linear-gradient(135deg, #059669, #047857);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.quick-connect-btn:active {
transform: translateY(0);
}
.quick-status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
margin-top: 8px;
}
.quick-status-badge.active {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.quick-status-badge.inactive {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
/* Login Section Styles */
.login-section {
background: rgba(15, 23, 37, 0.5);
border: 1px solid #2a3648;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.login-header {
margin-bottom: 1.5rem;
}
.login-header h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.login-header p {
margin: 0;
color: #94a3b8;
font-size: 0.875rem;
}
.quick-login-badge {
display: inline-block;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 600;
}
.login-form-group {
margin-bottom: 1rem;
}
.login-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: #cbd5e1;
}
.login-input {
width: 100%;
padding: 0.75rem;
background: #0f1725;
border: 1px solid #2a3648;
border-radius: 8px;
color: #e6edf3;
font-size: 0.875rem;
transition: border-color 0.15s;
}
.login-input:focus {
outline: none;
border-color: #3b82f6;
}
.login-input:disabled {
background: #1a2332;
color: #64748b;
cursor: not-allowed;
}
.login-input-readonly {
background: rgba(30, 41, 59, 0.5);
border-color: #334155;
}
.login-btn {
width: 100%;
padding: 0.875rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.15s;
margin-top: 0.5rem;
}
.login-btn-primary {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.login-btn-primary:hover {
background: linear-gradient(135deg, #059669, #047857);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.login-btn-primary:active {
transform: translateY(0);
}
.login-btn-secondary {
background: linear-gradient(135deg, #3b82f6, #9333ea);
color: white;
}
.login-btn-secondary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.login-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #64748b;
font-size: 0.875rem;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: #2a3648;
}
.info-box {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
padding: 0.75rem;
margin-top: 1rem;
font-size: 0.8125rem;
color: #94a3b8;
}
.info-box strong {
color: #60a5fa;
}
/* Connection Manager Styles */
.conn-section {
background: rgba(15, 23, 37, 0.5);
border: 1px solid #2a3648;
border-radius: 12px;
padding: 1.5rem;
}
.conn-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.conn-header h2 {
margin: 0;
font-size: 1.5rem;
}
.connections-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.conn-card {
position: relative;
background: rgba(30, 41, 59, 0.7);
border: 2px solid #2a3648;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.conn-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(59, 130, 246, 0.2);
}
.conn-card.active {
border-color: #10b981;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.05));
}
.conn-card.connecting {
border-color: #f59e0b;
}
.conn-status-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
border-radius: 12px 12px 0 0;
background: #2a3648;
}
.conn-card.active .conn-status-bar {
background: linear-gradient(90deg, #10b981, #34d399);
}
.conn-card.connecting .conn-status-bar {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
animation: connPulse 1.5s ease-in-out infinite;
}
@keyframes connPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.conn-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-top: 8px;
margin-bottom: 12px;
}
.conn-card-title {
font-size: 18px;
font-weight: 700;
color: #e6edf3;
}
.conn-card-actions {
display: flex;
gap: 4px;
}
.conn-icon-btn {
background: transparent;
border: none;
color: #9aa4b2;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 16px;
transition: all 0.15s;
}
.conn-icon-btn:hover {
background: #263244;
color: #e6edf3;
}
.conn-card-info {
font-size: 14px;
color: #9aa4b2;
line-height: 1.6;
}
.conn-info-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.conn-info-label {
font-weight: 600;
min-width: 60px;
}
.conn-status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
margin-top: 8px;
}
.conn-status-badge.active {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.conn-status-badge.inactive {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.conn-add-card {
background: transparent;
border: 2px dashed #2a3648;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 140px;
color: #9aa4b2;
font-weight: 600;
}
.conn-add-card:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.conn-add-icon {
font-size: 32px;
line-height: 1;
}
.conn-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
}
.conn-btn-primary {
background: linear-gradient(135deg, #3b82f6, #9333ea);
color: white;
}
.conn-btn-primary:hover {
opacity: 0.9;
}
.conn-btn-secondary {
background: #2a3648;
color: #e6edf3;
}
.conn-modal {
display: none;
position: fixed;
inset: 0;
z-index: 99999;
align-items: center;
justify-content: center;
}
.conn-modal[aria-hidden="false"] {
display: flex;
}
.conn-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.conn-modal__dialog {
position: relative;
background: #1a2332;
border: 1px solid #2a3648;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
z-index: 1;
}
.conn-modal__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.conn-modal__header h3 {
margin: 0;
font-size: 20px;
font-weight: 700;
}
.conn-close-btn {
background: transparent;
border: none;
color: #9aa4b2;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
line-height: 1;
}
.conn-close-btn:hover {
background: #263244;
color: #e6edf3;
}
.conn-form-group {
margin-bottom: 16px;
}
.conn-label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 14px;
}
.conn-input {
width: 100%;
padding: 10px 12px;
background: #0f1725;
border: 1px solid #2a3648;
border-radius: 8px;
color: #e6edf3;
font-size: 14px;
}
.conn-input:focus {
outline: none;
border-color: #3b82f6;
}
.conn-pw-wrap {
position: relative;
display: flex;
align-items: center;
}
.conn-pw-toggle {
position: absolute;
right: 8px;
background: transparent;
border: none;
color: #9aa4b2;
cursor: pointer;
padding: 6px;
border-radius: 6px;
}
.conn-pw-toggle:hover {
background: #263244;
}
.conn-form-actions {
display: flex;
gap: 8px;
margin-top: 24px;
}
.conn-toast-container {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 999999;
display: flex;
flex-direction: column;
gap: 8px;
}
.conn-toast {
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(71, 85, 105, 0.5);
color: #e2e8f0;
padding: 10px 14px;
border-radius: 10px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
font-size: 0.9rem;
opacity: 0;
transform: translateY(8px);
animation: connToastIn 200ms ease forwards;
}
.conn-toast.success {
border-color: rgba(16, 185, 129, 0.6);
}
.conn-toast.error {
border-color: rgba(239, 68, 68, 0.6);
}
@keyframes connToastIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes connToastOut {
to {
opacity: 0;
transform: translateY(8px);
}
}
</style>
</head>
<body>
<?php include __DIR__ . '/../core/auth/header.php'; ?>
<header class="topbar" aria-label="Top navigation">
<div class="topbar-inner">
<div id="buttonRow" role="tablist" aria-label="App sections"></div>
<div id="menuContainer" aria-label="More actions"></div>
</div>
</header>
<main class="container">
<!-- Quick SFTP Login -->
<div class="login-section">
<div class="login-header">
<h2>🔐 SFTP Connections</h2>
<button class="conn-btn conn-btn-primary" onclick="openConnectionModal()">+ New Connection</button>
</div>
<div class="connections-grid" style="margin-bottom: 1rem;">
<!-- Quick Connect Card -->
<div class="quick-connect-card" id="quickConnectCard" onclick="handleQuickConnectClick(event)">
<div class="quick-status-bar"></div>
<div class="quick-connect-header">
<div class="quick-connect-title">
⚡ DevBrewing Quick Connect
<span class="quick-badge">QUICK</span>
</div>
</div>
<div class="quick-connect-info">
<div class="quick-info-row">
<span class="quick-info-label">User:</span>
<span><?= htmlspecialchars($_SESSION['username']) ?></span>
</div>
</div>
<form id="quickLoginForm" onclick="event.stopPropagation()">
<input
type="password"
class="quick-password-input"
id="quickPassword"
placeholder="Enter SFTP password"
required
>
<button type="submit" class="quick-connect-btn">🚀 Connect</button>
</form>
<span class="quick-status-badge inactive" id="quickStatusBadge">⚫ Disconnected</span>
</div>
</div>
<!-- Custom Connections Grid -->
<div class="connections-grid" id="connectionsGrid">
<!-- Connections will be rendered here -->
</div>
</div>
</main>
<!-- Connection Modal -->
<div class="conn-modal" id="connectionModal" aria-hidden="true">
<div class="conn-modal__backdrop" onclick="closeConnectionModal()"></div>
<div class="conn-modal__dialog">
<div class="conn-modal__header">
<h3 id="modalTitle">New Connection</h3>
<button class="conn-close-btn" onclick="closeConnectionModal()">×</button>
</div>
<form id="connectionForm">
<input type="hidden" id="connectionId">
<div class="conn-form-group">
<label class="conn-label">Connection Name</label>
<input type="text" class="conn-input" id="connName" placeholder="My Server" required>
</div>
<div class="conn-form-group">
<label class="conn-label">Host</label>
<input type="text" class="conn-input" id="connHost" placeholder="server.example.com" required>
</div>
<div class="conn-form-group">
<label class="conn-label">Port</label>
<input type="number" class="conn-input" id="connPort" value="22" required>
</div>
<div class="conn-form-group">
<label class="conn-label">Username</label>
<input type="text" class="conn-input" id="connUser" required>
</div>
<div class="conn-form-group">
<label class="conn-label">Password</label>
<div class="conn-pw-wrap">
<input type="password" class="conn-input" id="connPass" required>
<button type="button" class="conn-pw-toggle" onclick="toggleConnPassword()">👁️</button>
</div>
</div>
<div class="conn-form-actions">
<button type="submit" class="conn-btn conn-btn-primary">Save Connection</button>
<button type="button" class="conn-btn conn-btn-secondary" onclick="closeConnectionModal()">Cancel</button>
</div>
<div id="formMessage"></div>
</form>
</div>
</div>
<!-- Toast Container -->
<div class="conn-toast-container" id="connToastContainer"></div>
<script>
// Global component registry
window.AppItems = [];
window.AppMenu = [];
</script>
<!-- Core system -->
<script src="/core/js/core.js" defer></script>
<script src="<?= asset('/core/js/overlay.js') ?>" defer></script>
<script src="<?= asset('filemanager.js') ?>" defer></script>
<script src="<?= asset('menu.js') ?>" defer></script>
<!-- Quick Login Handler -->
<script>
// Handle clicking on quick connect card to disconnect
window.handleQuickConnectClick = async function(event) {
// Don't disconnect if clicking on form elements
if (event.target.closest('form') || event.target.closest('input') || event.target.closest('button')) {
return;
}
const connections = getConnections();
const dbConn = connections.find(c => c.id === 'devbrewing_quick');
if (dbConn && dbConn.active) {
// Disconnect
try {
const formData = new FormData();
formData.append('sftp_action', 'disconnect');
await fetch(window.location.href, {
method: 'POST',
body: formData
});
dbConn.active = false;
saveConnections(connections);
renderConnections();
updateQuickConnectStatus(false);
showToast('Disconnected from DevBrewing', 'success');
} catch (err) {
showToast('Disconnect failed: ' + err.message, 'error');
}
}
};
// Update quick connect visual status
function updateQuickConnectStatus(isActive) {
const card = document.getElementById('quickConnectCard');
const badge = document.getElementById('quickStatusBadge');
if (isActive) {
card.classList.add('active');
badge.className = 'quick-status-badge active';
badge.textContent = '🟢 Connected';
} else {
card.classList.remove('active');
badge.className = 'quick-status-badge inactive';
badge.textContent = '⚫ Disconnected';
}
}
document.getElementById('quickLoginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('quickPassword').value;
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.textContent;
if (!password) {
showToast('❌ Please enter password', 'error');
return;
}
btn.disabled = true;
btn.textContent = '🔄 Connecting...';
try {
// ALWAYS disconnect any active connection first
const connections = getConnections();
const activeConn = connections.find(c => c.active);
if (activeConn) {
await disconnectFromServer();
// Wait for disconnect to complete
await new Promise(resolve => setTimeout(resolve, 300));
}
// Check if DevBrewing connection already exists
let dbConn = connections.find(c => c.id === 'devbrewing_quick');
if (!dbConn) {
// Create new DevBrewing connection
dbConn = {
id: 'devbrewing_quick',
name: 'DevBrewing SFTP',
host: 'files.devbrewing.com',
port: 22,
username: '<?= htmlspecialchars($_SESSION['username']) ?>',
password: password,
active: false
};
connections.push(dbConn);
} else {
// Update password
dbConn.password = password;
}
// Now connect using the same method as regular connections
const formData = new FormData();
formData.append('sftp_action', 'connect');
formData.append('host', 'files.devbrewing.com');
formData.append('port', 22);
formData.append('username', '<?= htmlspecialchars($_SESSION['username']) ?>');
formData.append('password', password);
const res = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await res.json();
console.log('Quick connect response:', data);
if (data.success) {
// ENSURE ONLY this connection is active
connections.forEach(c => c.active = false);
dbConn.active = true;
saveConnections(connections);
renderConnections();
updateQuickConnectStatus(true);
showToast(`✅ Connected to DevBrewing`, 'success');
// DON'T clear password field - keep it filled
// document.getElementById('quickPassword').value = '';
} else {
throw new Error(data.message || 'Connection failed');
}
} catch (err) {
console.error('Quick connect error:', err);
showToast(`❌ Connection failed: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
// Check status on load and restore password
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const connections = getConnections();
const dbConn = connections.find(c => c.id === 'devbrewing_quick');
if (dbConn) {
updateQuickConnectStatus(dbConn.active || false);
// Restore saved password to input field
if (dbConn.password) {
document.getElementById('quickPassword').value = dbConn.password;
}
}
}, 200);
});
// Listen for connection changes to update quick connect status
const originalSaveConnections = window.saveConnections;
window.saveConnections = function(connections) {
originalSaveConnections(connections);
const dbConn = connections.find(c => c.id === 'devbrewing_quick');
updateQuickConnectStatus(dbConn?.active || false);
};
</script>
<!-- Connection Manager Script -->
<script>
(function () {
const STORAGE_KEY = 'sftp_connections';
window.openConnectionModal = function(connectionId = null) {
const modal = document.getElementById('connectionModal');
const form = document.getElementById('connectionForm');
const title = document.getElementById('modalTitle');
form.reset();
document.getElementById('formMessage').innerHTML = '';
if (connectionId) {
const connections = getConnections();
const conn = connections.find(c => c.id === connectionId);
if (conn) {
title.textContent = 'Edit Connection';
document.getElementById('connectionId').value = conn.id;
document.getElementById('connName').value = conn.name;
document.getElementById('connHost').value = conn.host;
document.getElementById('connPort').value = conn.port;
document.getElementById('connUser').value = conn.username;
document.getElementById('connPass').value = conn.password;
}
} else {
title.textContent = 'New Connection';
document.getElementById('connectionId').value = '';
}
modal.setAttribute('aria-hidden', 'false');
};
window.closeConnectionModal = function() {
document.getElementById('connectionModal').setAttribute('aria-hidden', 'true');
};
window.toggleConnPassword = function() {
const input = document.getElementById('connPass');
const btn = event.target;
input.type = input.type === 'password' ? 'text' : 'password';
btn.textContent = input.type === 'password' ? '👁️' : '🙈';
};
window.getConnections = function() {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
};
window.saveConnections = function(connections) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(connections));
};
window.showToast = function(message, type = 'success', timeout = 2500) {
const container = document.getElementById('connToastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `conn-toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'connToastOut 160ms ease forwards';
setTimeout(() => container.removeChild(toast), 170);
}, timeout);
};
window.renderConnections = function() {
const grid = document.getElementById('connectionsGrid');
if (!grid) return;
const connections = getConnections();
grid.innerHTML = '';
if (connections.length === 0) {
grid.innerHTML = `
<p style="color: #64748b; text-align: center; padding: 2rem;">
No saved connections yet.<br>Click "+ New Connection" to add one.
</p>
`;
return;
}
connections.forEach(conn => {
// Skip DevBrewing Quick connection - it's shown separately
if (conn.id === 'devbrewing_quick') {
return;
}
const card = document.createElement('div');
card.className = `conn-card ${conn.active ? 'active' : ''}`;
card.innerHTML = `
<div class="conn-status-bar"></div>
<div class="conn-card-header">
<div class="conn-card-title">${escapeHtml(conn.name)}</div>
<div class="conn-card-actions">
<button class="conn-icon-btn" onclick="editConnection('${conn.id}')" title="Edit">✏️</button>
<button class="conn-icon-btn" onclick="deleteConnection('${conn.id}')" title="Delete">🗑️</button>
</div>
</div>
<div class="conn-card-info">
<div class="conn-info-row">
<span class="conn-info-label">Host:</span>
<span>${escapeHtml(conn.host)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">User:</span>
<span>${escapeHtml(conn.username)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Port:</span>
<span>${conn.port}</span>
</div>
</div>
<span class="conn-status-badge ${conn.active ? 'active' : 'inactive'}">
${conn.active ? '🟢 Connected' : '⚫ Disconnected'}
</span>
`;
card.addEventListener('click', (e) => {
if (!e.target.closest('.conn-icon-btn')) {
if (conn.active) {
disconnectConnection(conn.id);
} else {
connectToServer(conn.id, card);
}
}
});
grid.appendChild(card);
});
};
window.editConnection = function(id) {
openConnectionModal(id);
};
window.deleteConnection = async function(id) {
if (!confirm('Are you sure you want to delete this connection?')) return;
const connections = getConnections();
const deleted = connections.find(c => c.id === id);
if (deleted && deleted.active) {
await disconnectFromServer();
}
const filtered = connections.filter(c => c.id !== id);
saveConnections(filtered);
renderConnections();
showToast('Connection deleted', 'success');
};
window.disconnectConnection = async function(id) {
try {
const formData = new FormData();
formData.append('sftp_action', 'disconnect');
await fetch(window.location.href, {
method: 'POST',
body: formData
});
const connections = getConnections();
const conn = connections.find(c => c.id === id);
if (conn) {
conn.active = false;
saveConnections(connections);
renderConnections();
showToast(`Disconnected from ${conn.name}`, 'success');
}
} catch (err) {
showToast('Disconnect failed: ' + err.message, 'error');
}
};
async function connectToServer(id, card) {
const connections = getConnections();
const conn = connections.find(c => c.id === id);
if (!conn) return;
card.classList.add('connecting');
try {
// ALWAYS disconnect any active connection first
const activeConn = connections.find(c => c.active);
if (activeConn) {
await disconnectFromServer();
// Wait a moment for disconnect to complete
await new Promise(resolve => setTimeout(resolve, 300));
}
const formData = new FormData();
formData.append('sftp_action', 'connect');
formData.append('host', conn.host);
formData.append('port', conn.port);
formData.append('username', conn.username);
formData.append('password', conn.password);
const res = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await res.json();
console.log('Connection response:', data);
if (data.success) {
// Ensure ONLY this connection is active
connections.forEach(c => c.active = false);
conn.active = true;
saveConnections(connections);
renderConnections();
showToast(`✅ Connected to ${conn.name}`, 'success');
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(`❌ Connection failed: ${err.message}`, 'error');
} finally {
card.classList.remove('connecting');
}
}
async function disconnectFromServer() {
try {
const formData = new FormData();
formData.append('sftp_action', 'disconnect');
await fetch(window.location.href, {
method: 'POST',
body: formData
});
const connections = getConnections();
connections.forEach(c => c.active = false);
saveConnections(connections);
} catch (err) {
console.error('Disconnect error:', err);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.addEventListener('submit', (e) => {
if (e.target.id === 'connectionForm') {
e.preventDefault();
const connections = getConnections();
const id = document.getElementById('connectionId').value || Date.now().toString();
const name = document.getElementById('connName').value;
const host = document.getElementById('connHost').value;
const port = parseInt(document.getElementById('connPort').value);
const username = document.getElementById('connUser').value;
const password = document.getElementById('connPass').value;
const existingIndex = connections.findIndex(c => c.id === id);
const connection = {
id,
name,
host,
port,
username,
password,
active: existingIndex >= 0 ? connections[existingIndex].active : false
};
if (existingIndex >= 0) {
connections[existingIndex] = connection;
showToast('Connection updated', 'success');
} else {
connections.push(connection);
showToast('Connection saved', 'success');
}
saveConnections(connections);
renderConnections();
closeConnectionModal();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('connectionModal');
if (modal && modal.getAttribute('aria-hidden') === 'false') {
closeConnectionModal();
}
}
});
document.addEventListener('DOMContentLoaded', () => {
renderConnections();
setTimeout(async () => {
try {
const formData = new FormData();
formData.append('sftp_action', 'status');
const res = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await res.json();
const connections = getConnections();
const hasActiveLocal = connections.some(c => c.active);
if (!data.data?.connected && hasActiveLocal) {
connections.forEach(c => c.active = false);
saveConnections(connections);
renderConnections();
}
} catch (err) {
console.error('Status check failed:', err);
}
}, 100);
});
console.log('[connectionmanager] Loaded - dual login system with quick connect');
})();
</script>
<!-- Render chips + menu -->
<script defer>
document.addEventListener('DOMContentLoaded', () => {
const row = document.getElementById('buttonRow');
row.innerHTML = '';
(window.AppItems || []).forEach((item, i) => {
const btn = document.createElement('button');
btn.className = 'chip';
btn.textContent = item.title || `Item ${i+1}`;
btn.onclick = () => window.AppOverlay && AppOverlay.open(window.AppItems, i, btn);
row.appendChild(btn);
});
const menuContainer = document.getElementById('menuContainer');
menuContainer.innerHTML = '';
const menuItems = window.AppMenu || [];
if (menuItems.length > 0) {
const trigger = document.createElement('button');
trigger.className = 'chip menu-trigger';
trigger.textContent = '⋮';
menuContainer.appendChild(trigger);
const dropdown = document.createElement('div');
dropdown.className = 'menu-list';
menuContainer.appendChild(dropdown);
menuItems.forEach((m, idx) => {
const item = document.createElement('button');
item.className = 'menu-item';
item.textContent = m.label || `Action ${idx+1}`;
item.onclick = () => { dropdown.classList.remove('open'); m.action && m.action(); };
dropdown.appendChild(item);
});
trigger.addEventListener('click', (e)=>{
e.stopPropagation();
dropdown.classList.toggle('open');
});
document.addEventListener('click', (e)=>{
if (!menuContainer.contains(e.target)) dropdown.classList.remove('open');
});
}
});
</script>
</body>
</html>