Format code

This commit is contained in:
LukeGus
2025-08-18 00:13:21 -05:00
parent fa64e98ef9
commit c1d06028c3
31 changed files with 1791 additions and 1780 deletions

View File

@@ -49,7 +49,6 @@ function AppContent() {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Clear invalid JWT
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
})
.finally(() => setAuthLoading(false));
@@ -85,27 +84,19 @@ function AppContent() {
setUsername(authData.username)
}
// Determine what to show based on current tab
const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
console.log('Current tab:', currentTab);
console.log('Current tab data:', currentTabData);
console.log('Show terminal view:', showTerminalView);
console.log('All tabs:', tabs);
return (
<div>
{/* Enhanced background overlay - detailed pattern when not authenticated */}
{!isAuthenticated && !authLoading && (
<div
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
aria-hidden="true"
>
{/* Diagonal stripes pattern */}
<div className="absolute inset-0 opacity-20">
<div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient(
@@ -118,7 +109,6 @@ function AppContent() {
}} />
</div>
{/* Subtle grid pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
@@ -127,12 +117,10 @@ function AppContent() {
}} />
</div>
{/* Radial gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
</div>
)}
{/* Show login form directly when not authenticated */}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
@@ -145,7 +133,6 @@ function AppContent() {
</div>
)}
{/* Show sidebar layout only when authenticated */}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
@@ -153,7 +140,6 @@ function AppContent() {
isAdmin={isAdmin}
username={username}
>
{/* Always render TerminalView to maintain terminal persistence */}
<div
className="h-screen w-full"
style={{
@@ -168,7 +154,6 @@ function AppContent() {
<AppView isTopbarOpen={isTopbarOpen} />
</div>
{/* Always render Homepage to keep it mounted */}
<div
className="h-screen w-full"
style={{
@@ -189,7 +174,6 @@ function AppContent() {
/>
</div>
{/* Always render SSH Manager but toggle visibility for persistence */}
<div
className="h-screen w-full"
style={{
@@ -204,7 +188,6 @@ function AppContent() {
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
{/* Admin Settings tab */}
<div
className="h-screen w-full"
style={{
@@ -219,7 +202,6 @@ function AppContent() {
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
{/* Legacy views removed; tab system controls main content */}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}

View File

@@ -41,93 +41,339 @@ const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
CREATE TABLE IF NOT EXISTS users
(
id
TEXT
PRIMARY
KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
is_oidc
INTEGER
NOT
NULL
DEFAULT
0,
client_id
TEXT
NOT
NULL,
client_secret
TEXT
NOT
NULL,
issuer_url
TEXT
NOT
NULL,
authorization_url
TEXT
NOT
NULL,
token_url
TEXT
NOT
NULL,
redirect_uri
TEXT,
identifier_path
TEXT
NOT
NULL,
name_path
TEXT
NOT
NULL,
scopes
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
CREATE TABLE IF NOT EXISTS settings
(
key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
CREATE TABLE IF NOT EXISTS ssh_data
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
name
TEXT,
ip
TEXT
NOT
NULL,
port
INTEGER
NOT
NULL,
username
TEXT
NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_file_manager
INTEGER
NOT
NULL
DEFAULT
1,
default_path
TEXT,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
updated_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
CREATE TABLE IF NOT EXISTS file_manager_recent
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
CREATE TABLE IF NOT EXISTS file_manager_pinned
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
CREATE TABLE IF NOT EXISTS dismissed_alerts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
`);

View File

@@ -10,12 +10,8 @@ app.use(cors({
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Increase JSON body parser limit for larger file uploads
app.use(express.json({limit: '100mb'}));
app.use(express.urlencoded({limit: '100mb', extended: true}));
// Add raw body parser for very large files
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
const sshIconSymbol = '📁';
@@ -314,9 +310,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 60000); // Increased timeout to 60 seconds
}, 60000);
// Try SFTP first, fallback to command line if it fails
const trySFTP = () => {
try {
sshConn.client.sftp((err, sftp) => {
@@ -326,7 +321,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
return;
}
// Convert content to buffer
let fileBuffer;
try {
if (typeof content === 'string') {
@@ -345,7 +339,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
return;
}
// Create write stream with error handling
const writeStream = sftp.createWriteStream(filePath);
let hasError = false;
@@ -378,7 +371,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
}
});
// Write the buffer to the stream
try {
writeStream.write(fileBuffer);
writeStream.end();
@@ -395,7 +387,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
}
};
// Fallback method using command line
const tryFallbackMethod = () => {
try {
const base64Content = Buffer.from(content, 'utf8').toString('base64');
@@ -457,11 +448,9 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
}
};
// Start with SFTP
trySFTP();
});
// Upload file route
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
const {sessionId, path: filePath, content, fileName} = req.body;
const sshConn = sshSessions[sessionId];
@@ -488,9 +477,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 60000); // Increased timeout to 60 seconds
}, 60000);
// Try SFTP first, fallback to command line if it fails
const trySFTP = () => {
try {
sshConn.client.sftp((err, sftp) => {
@@ -500,7 +488,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
return;
}
// Convert content to buffer
let fileBuffer;
try {
if (typeof content === 'string') {
@@ -519,7 +506,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
return;
}
// Create write stream with error handling
const writeStream = sftp.createWriteStream(fullPath);
let hasError = false;
@@ -552,7 +538,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}
});
// Write the buffer to the stream
try {
writeStream.write(fileBuffer);
writeStream.end();
@@ -569,12 +554,10 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}
};
// Fallback method using command line with chunked approach
const tryFallbackMethod = () => {
try {
// Convert content to base64 and split into smaller chunks if needed
const base64Content = Buffer.from(content, 'utf8').toString('base64');
const chunkSize = 1000000; // 1MB chunks
const chunkSize = 1000000;
const chunks = [];
for (let i = 0; i < base64Content.length; i += chunkSize) {
@@ -582,7 +565,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}
if (chunks.length === 1) {
// Single chunk - use simple approach
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
@@ -635,12 +617,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
});
});
} else {
// Multiple chunks - use chunked approach
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
let writeCommand = `> '${escapedPath}'`; // Start with empty file
let writeCommand = `> '${escapedPath}'`;
chunks.forEach((chunk, index) => {
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
@@ -703,11 +684,9 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}
};
// Start with SFTP
trySFTP();
});
// Create new file route
app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
const {sessionId, path: filePath, fileName, content = ''} = req.body;
const sshConn = sshSessions[sessionId];
@@ -804,7 +783,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
});
});
// Create folder route
app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
const {sessionId, path: folderPath, folderName} = req.body;
const sshConn = sshSessions[sessionId];
@@ -901,7 +879,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
});
});
// Delete file/folder route
app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
const {sessionId, path: itemPath, isDirectory} = req.body;
const sshConn = sshSessions[sessionId];
@@ -999,7 +976,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
});
});
// Rename file/folder route
app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
const {sessionId, oldPath, newName} = req.body;
const sshConn = sshSessions[sessionId];

View File

@@ -21,7 +21,7 @@ type HostStatus = 'online' | 'offline';
type StatusEntry = {
status: HostStatus;
lastChecked: string; // ISO string
lastChecked: string;
};
const app = express();
@@ -30,7 +30,6 @@ app.use(cors({
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Fallback explicit CORS headers to cover any edge cases
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
@@ -42,7 +41,6 @@ app.use((req, res, next) => {
});
app.use(express.json());
// Logger (customized for Server Stats)
const statsIconSymbol = '📡';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -69,10 +67,8 @@ const logger = {
}
};
// In-memory state of last known statuses
const hostStatuses: Map<number, StatusEntry> = new Map();
// Fetch all hosts from the database service (internal endpoint, no JWT)
async function fetchAllHosts(): Promise<HostRecord[]> {
const url = 'http://localhost:8081/ssh/db/host/internal';
try {
@@ -112,9 +108,7 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
port: host.port || 22,
username: host.username || 'root',
readyTimeout: 10_000,
algorithms: {
// keep defaults minimal to avoid negotiation issues
}
algorithms: {}
} as ConnectConfig;
if (host.authType === 'password') {
@@ -138,7 +132,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
const onError = (err: Error) => {
if (!settled) {
settled = true;
try { client.end(); } catch {}
try {
client.end();
} catch {
}
reject(err);
}
};
@@ -148,7 +145,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
const result = await fn(client);
if (!settled) {
settled = true;
try { client.end(); } catch {}
try {
client.end();
} catch {
}
resolve(result);
}
} catch (err: any) {
@@ -166,7 +166,11 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
});
}
function execCommand(client: Client, command: string): Promise<{ stdout: string; stderr: string; code: number | null; }> {
function execCommand(client: Client, command: string): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, {pty: false}, (err, stream) => {
if (err) return reject(err);
@@ -190,7 +194,7 @@ function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefi
if (parts[0] !== 'cpu') return undefined;
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); // idle + iowait
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return {total, idle};
}
@@ -210,7 +214,6 @@ async function collectMetrics(host: HostRecord): Promise<{
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
}> {
return withSshConnection(host, async (client) => {
// CPU
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
@@ -245,7 +248,6 @@ async function collectMetrics(host: HostRecord): Promise<{
loadTriplet = null;
}
// Memory
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
@@ -256,7 +258,7 @@ async function collectMetrics(host: HostRecord): Promise<{
const line = lines.find(l => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null; // in kB
return m ? Number(m[0]) : null;
};
const totalKb = getVal('MemTotal:');
const availKb = getVal('MemAvailable:');
@@ -272,14 +274,12 @@ async function collectMetrics(host: HostRecord): Promise<{
totalGiB = null;
}
// Disk
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
try {
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
// Expected columns: Filesystem Size Used Avail Use% Mounted
const parts = line.split(/\s+/);
if (parts.length >= 6) {
totalHuman = parts[1] || null;
@@ -296,7 +296,11 @@ async function collectMetrics(host: HostRecord): Promise<{
return {
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
memory: { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null },
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
},
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
};
});
@@ -310,7 +314,10 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
const onDone = (result: boolean) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
try {
socket.destroy();
} catch {
}
resolve(result);
};
@@ -344,7 +351,6 @@ async function pollStatusesOnce(): Promise<void> {
}
app.get('/status', async (req, res) => {
// Return current cached statuses; if empty, trigger a poll
if (hostStatuses.size === 0) {
await pollStatusesOnce();
}
@@ -409,7 +415,6 @@ app.listen(PORT, async () => {
}
});
// Background polling every minute
setInterval(() => {
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
}, 60_000);

View File

@@ -132,15 +132,14 @@
}
}
/* Thin scrollbar utility for scrollable containers */
.thin-scrollbar {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #303032 transparent; /* Firefox */
scrollbar-width: thin;
scrollbar-color: #303032 transparent;
}
.thin-scrollbar::-webkit-scrollbar {
height: 6px; /* horizontal */
width: 6px; /* vertical, if any */
height: 6px;
width: 6px;
}
.thin-scrollbar::-webkit-scrollbar-track {
@@ -154,7 +153,6 @@
background-clip: content-box;
}
/* Thin scrollbar styles */
.thin-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -173,7 +171,6 @@
background: #5a5a5d;
}
/* Custom scrollbar for Firefox */
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #434345 #18181b;

View File

@@ -35,11 +35,9 @@ interface AdminSettingsProps {
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
// Registration toggle
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
// OIDC config
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
// Users/admins
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
// Preload OIDC config and users
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
.then(res => { if (res.data) setOidcConfig(res.data); })
.catch(() => {});
.then(res => {
if (res.data) setOidcConfig(res.data);
})
.catch(() => {
});
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load initial registration toggle status
React.useEffect(() => {
API.get("/registration-allowed")
.then(res => {
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setAllowRegistration(res.data.allowed);
}
})
.catch(() => {});
.catch(() => {
});
}, []);
const fetchUsers = async () => {
@@ -161,7 +164,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
try {
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
fetchUsers();
} catch {}
} catch {
}
};
const deleteUser = async (username: string) => {
@@ -170,7 +174,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
try {
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
fetchUsers();
} catch {}
} catch {
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
};
return (
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1>
@@ -217,7 +223,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/>
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p>
<p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && (
<Alert variant="destructive">
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required />
<Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required />
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required />
<Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required />
<Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required />
<Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required />
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required />
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required />
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button>
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
</div>
{oidcSuccess && (
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)}
</TableCell>
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}>
<Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required />
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div>
</div>
{makeAdminError && (
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell>
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
Remove Admin
</Button>

View File

@@ -44,7 +44,6 @@ export function Homepage({
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
// Update local state when props change
useEffect(() => {
setLoggedIn(isAuthenticated);
}, [isAuthenticated]);

View File

@@ -149,13 +149,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
@@ -309,13 +307,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname);
})

View File

@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
});
};
// Coalesce layout → measure → fit callbacks
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
});
};
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
useEffect(() => {
hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => {
scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const HEADER_H = 28;
@@ -112,7 +108,17 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 };
styles[mainTab.id] = {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
@@ -139,7 +145,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'}
: {
@@ -211,16 +216,63 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
className="h-full w-full" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
@@ -234,18 +286,65 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b, c] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
@@ -254,9 +353,31 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
@@ -267,18 +388,65 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b, c, d] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
@@ -287,17 +455,63 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}>
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}>
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{d.title}</div>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${d.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(d.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{d.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
};
return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}>
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">

View File

@@ -110,21 +110,6 @@ export function LeftSidebar({
children,
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
@@ -138,15 +123,15 @@ export function LeftSidebar({
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
// Tabs context for opening SSH Manager tab
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
@@ -166,7 +151,6 @@ export function LeftSidebar({
setCurrentTab(id);
};
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
@@ -202,23 +186,16 @@ export function LeftSidebar({
}
}, [isAdmin]);
// SSH Hosts data fetching
const fetchHosts = React.useCallback(async () => {
try {
const newHosts = await getSSHHosts();
// Show all hosts in sidebar, regardless of terminal setting
// Terminal visibility is handled in the UI components
const prevHosts = prevHostsRef.current;
// Create a stable map of existing hosts by ID for comparison
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
// Check if there are any meaningful changes
let hasChanges = false;
// Check for new hosts, removed hosts, or changed hosts
if (newHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
@@ -229,7 +206,6 @@ export function LeftSidebar({
break;
}
// Only check fields that affect the display
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
@@ -247,7 +223,6 @@ export function LeftSidebar({
}
if (hasChanges) {
// Use a small delay to batch updates and reduce jittering
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
@@ -264,7 +239,6 @@ export function LeftSidebar({
return () => clearInterval(interval);
}, [fetchHosts]);
// Immediate refresh when SSH hosts are changed elsewhere in the app
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
@@ -273,13 +247,11 @@ export function LeftSidebar({
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
// Filter and organize hosts with stable references
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
@@ -323,68 +295,6 @@ export function LeftSidebar({
return [...pinned, ...rest];
}, []);
const handleToggle = async (checked: boolean) => {
if (!isAdmin) {
return;
}
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch(
"/registration-allowed",
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAdmin) {
return;
}
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
if (missingFields.length > 0) {
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post(
"/oidc-config",
oidcConfig,
{headers: {Authorization: `Bearer ${jwt}`}}
);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({
...prev,
[field]: value
}));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -427,7 +337,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch users:", err);
} finally {
setUsersLoading(false);
}
@@ -447,7 +356,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch admin count:", err);
}
};
@@ -494,7 +402,6 @@ export function LeftSidebar({
);
fetchUsers();
} catch (err: any) {
console.error("Failed to remove admin status:", err);
}
};
@@ -513,7 +420,6 @@ export function LeftSidebar({
});
fetchUsers();
} catch (err: any) {
console.error("Failed to delete user:", err);
}
};
@@ -536,14 +442,15 @@ export function LeftSidebar({
<Separator className="p-0.25"/>
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<Button className="m-2 flex flex-row font-semibold" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/>
Host Manager
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
{/* Search Input */}
<div className="bg-[#131316] rounded-lg">
<Input
value={search}
@@ -554,16 +461,15 @@ export function LeftSidebar({
/>
</div>
{/* Error Display */}
{hostsError && (
<div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
<div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError}
</div>
</div>
)}
{/* Loading State */}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
@@ -572,7 +478,6 @@ export function LeftSidebar({
</div>
)}
{/* Hosts by Folder */}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
@@ -608,7 +513,7 @@ export function LeftSidebar({
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => {
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
@@ -616,12 +521,12 @@ export function LeftSidebar({
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={handleLogout}>
onClick={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setDeleteAccountOpen(true)}
onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
@@ -635,383 +540,74 @@ export function LeftSidebar({
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet */}
{isAdmin && (
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
<SheetHeader className="px-6 pb-4">
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="px-6">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Reg
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
{/* Registration Settings Tab */}
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
<ChevronRight size={10}/>
</div>
</TabsContent>
{/* OIDC Configuration Tab */}
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication
(OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
{deleteAccountOpen && (
<div
className="fixed inset-0 z-[999999] flex"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)',
willChange: 'z-index'
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
{/* Users Management Tab */}
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<div
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 9999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close Delete Account"
>
{usersLoading ? "Loading..." : "Refresh"}
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading users...
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* Admins Management Tab */}
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
{/* Add New Admin Form */}
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin"
required
/>
<Button
type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}
>
{makeAdminLoading ? "Adding..." : "Make Admin"}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
{/* Current Admins Table */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(user => user.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
disabled={admin.username === username}
>
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<SheetFooter className="px-6 pt-6 pb-6">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
{/* Delete Account Confirmation Sheet */}
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SheetContent side="left" className="w-[400px]">
<SheetHeader className="pb-0">
<SheetTitle>Delete Account</SheetTitle>
<SheetDescription>
<div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all
associated data.
</SheetDescription>
</SheetHeader>
<div className="pb-4 px-4 flex flex-col gap-4">
</div>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
@@ -1076,19 +672,18 @@ export function LeftSidebar({
</div>
</form>
</div>
</SheetContent>
</Sheet>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</div>
</div>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
<ChevronRight size={10} />
className="flex-1"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div>
)}
</div>

View File

@@ -1,7 +1,14 @@
import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react";
import {
Home,
SeparatorVertical,
X,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon
} from "lucide-react";
interface TabProps {
tabType: string;
@@ -17,7 +24,19 @@ interface TabProps {
disableClose?: boolean;
}
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement {
export function Tab({
tabType,
title,
isActive,
onActivate,
onClose,
onSplit,
canSplit = false,
canClose = false,
disableActivate = false,
disableSplit = false,
disableClose = false
}: TabProps): React.ReactElement {
if (tabType === "home") {
return (
<Button
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
onClick={onActivate}
disabled={disableActivate}
>
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? <FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
</Button>
{canSplit && (

View File

@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
});
if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;

View File

@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleStartRecording = () => {
setIsRecording(true);
// Focus on the input when recording starts
setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus();
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const value = e.currentTarget.value;
let commandToSend = '';
// Handle special keys and control sequences
if (e.ctrlKey || e.metaKey) {
// Control sequences
if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault();
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
e.preventDefault();
}
// Send the command to all selected terminals
if (commandToSend) {
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
@@ -191,7 +185,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
// Handle regular character input
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach(tabId => {
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
// Get terminal tabs for selection
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) {
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
padding: "0"
}}
>
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => {
const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer || isFileManager;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
@@ -300,7 +291,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div>
)}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && (
<div
className="fixed inset-0 z-[999999] flex justify-end"
@@ -378,7 +368,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Select terminals:</label>
<label className="text-sm font-medium text-white">Select
terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => (
<Button
@@ -400,7 +391,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label>
<label className="text-sm font-medium text-white">Type commands (all
keys supported):</label>
<Input
id="ssh-tools-input"
placeholder="Type here"
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly
/>
<p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected terminal(s).
Commands will be sent to {selectedTabIds.length} selected
terminal(s).
</p>
</div>
</>

View File

@@ -74,11 +74,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false);
// New state for operations
const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/');
// Delete modal state
const [deletingItem, setDeletingItem] = useState<any | null>(null);
const sidebarRef = useRef<any>(null);
@@ -86,7 +84,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost);
// Defer to ensure sidebar is mounted
setTimeout(() => {
try {
const path = initialHost.defaultPath || '/';
@@ -448,16 +445,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
}
};
// Host is locked; no external host change from UI
const handleHostChange = (_host: SSHHost | null) => {
};
const handleOperationComplete = () => {
// Refresh the sidebar files
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
// Refresh home data
if (currentHost) {
fetchHomeData();
}
@@ -471,17 +465,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
toast.error(error);
};
// Function to update current path from sidebar
const updateCurrentPath = (newPath: string) => {
setCurrentPath(newPath);
};
// Function to handle delete from sidebar
const handleDeleteFromSidebar = (item: any) => {
setDeletingItem(item);
};
// Function to perform the actual delete
const performDelete = async (item: any) => {
if (!currentHost?.id) return;
@@ -552,8 +543,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
</div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
{/* Tab list scrollable area */}
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
@@ -608,9 +599,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
display: 'flex',
flexDirection: 'column'
}}>
{/* Success/Error Messages */}
{/* The custom alert divs are removed, so this block is no longer needed. */}
{activeTab === 'home' ? (
<div className="flex h-full">
<div className="flex-1">
@@ -658,13 +646,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
)}
</div>
{/* Delete Confirmation Modal */}
{deletingItem && (
<div className="fixed inset-0 z-[99999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60"></div>
{/* Modal */}
<div className="relative h-full flex items-center justify-center">
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">

View File

@@ -128,7 +128,8 @@ export function FileManagerHomeView({
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
Add
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span>

View File

@@ -83,7 +83,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
// Context menu state
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
@@ -96,21 +95,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
item: null
});
// Rename state
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
// when host changes, set path and connect
const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
@@ -283,34 +279,27 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Context menu dimensions (approximate)
const menuWidth = 160; // min-w-[160px]
const menuHeight = 80; // Approximate height for 2 menu items
const menuWidth = 160;
const menuHeight = 80;
// Calculate position
let x = e.clientX;
let y = e.clientY;
// Adjust X position if menu would go off right edge
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
// Adjust Y position if menu would go off bottom edge
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
// Ensure menu doesn't go off left edge
if (x < 0) {
x = 0;
}
// Ensure menu doesn't go off top edge
if (y < 0) {
y = 0;
}
@@ -369,12 +358,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
};
const startDelete = (item: any) => {
// Call the parent's delete handler instead of managing locally
onDeleteItem?.(item);
closeContextMenu();
};
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener('click', handleClickOutside);
@@ -392,7 +379,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
@@ -440,7 +427,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false; // Deletion is handled by parent
const isDeleting = false;
return (
<div
@@ -519,7 +506,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
>
@@ -555,7 +541,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</div>
</div>
{/* Context Menu */}
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"

View File

@@ -63,48 +63,6 @@ export function FileManagerLeftSidebarFileViewer({
}: FileManagerLeftSidebarVileViewerProps) {
return (
<div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="flex flex-col gap-1">
<Button
variant={!isSSHMode ? 'secondary' : 'ghost'}
className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal}
>
<Server className="w-4 h-4 mr-2"/> Local Files
</Button>
{sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group">
<Button
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4"/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
))}
</div>
</div>
{/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span

View File

@@ -187,7 +187,6 @@ export function FileManagerOperations({
return (
<div className="p-4 space-y-4">
{/* Operation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
@@ -236,7 +235,6 @@ export function FileManagerOperations({
</Button>
</div>
{/* Current Path Display */}
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
<div className="flex items-center gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400"/>
@@ -247,7 +245,6 @@ export function FileManagerOperations({
<Separator className="p-0.25 bg-[#303032]"/>
{/* Upload File Modal */}
{showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-2">
@@ -331,7 +328,6 @@ export function FileManagerOperations({
</Card>
)}
{/* Create File Modal */}
{showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
@@ -383,7 +379,6 @@ export function FileManagerOperations({
</Card>
)}
{/* Create Folder Modal */}
{showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
@@ -435,7 +430,6 @@ export function FileManagerOperations({
</Card>
)}
{/* Delete Modal */}
{showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
@@ -507,7 +501,6 @@ export function FileManagerOperations({
</Card>
)}
{/* Rename Modal */}
{showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">

View File

@@ -55,7 +55,6 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
}
};
// Dynamic margins similar to TerminalView but with 16px gaps when retracted
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;

View File

@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port
(Source refers to the Current Connection Details in the General tab)</FormLabel>
(Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormControl>
<Input
placeholder="22" {...sourcePortField} />

View File

@@ -14,27 +14,30 @@ interface ServerProps {
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean; // when rendered inside a pane in TerminalView
embedded?: boolean;
}
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false
}: ServerProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const {addTab} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
// Listen for host configuration changes
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
// Always fetch latest host config when component mounts or hostConfig changes
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
// Import the getSSHHosts function to fetch updated host data
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
@@ -42,19 +45,15 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
setCurrentHostConfig(updatedHost);
}
} catch (error) {
console.error('Failed to fetch latest host config:', error);
}
}
};
// Fetch immediately when component mounts or hostConfig changes
fetchLatestHostConfig();
// Also listen for SSH hosts changed event to refresh host config
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
// Import the getSSHHosts function to fetch updated host data
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
@@ -62,7 +61,6 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
setCurrentHostConfig(updatedHost);
}
} catch (error) {
console.error('Failed to refresh host config:', error);
}
}
};
@@ -233,8 +231,10 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
{/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
<div
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div>
)}

View File

@@ -17,7 +17,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
{hostConfig, isVisible, splitScreen = false},
ref
) {
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
@@ -27,20 +26,22 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false);
// Debounce/stabilize resize notifications
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]);
useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {}
} catch (_) {
}
}
function scheduleNotify(cols: number, rows: number) {
@@ -85,7 +86,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {}
} catch (_) {
}
},
refresh: () => hardRefresh(),
}), [terminal]);
@@ -119,7 +121,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
await navigator.clipboard.writeText(text);
return;
}
} catch (_) {}
} catch (_) {
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
@@ -127,7 +130,11 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); }
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
async function readTextFromClipboard(): Promise<string> {
@@ -135,7 +142,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch (_) {}
} catch (_) {
}
return '';
}
@@ -175,16 +183,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return;
e.preventDefault(); e.stopPropagation();
e.preventDefault();
e.stopPropagation();
try {
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); }
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else {
const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText);
}
} catch (_) {}
} catch (_) {
}
};
element?.addEventListener('contextmenu', handleContextMenu);
@@ -221,8 +234,14 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); });
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000);
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
});
ws.addEventListener('message', (event) => {
@@ -230,13 +249,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.data);
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
else if (msg.type === 'connected') { }
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); }
} catch (error) { console.error('Error parsing WebSocket message:', error); }
else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
}
});
ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); });
ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); });
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
});
@@ -245,7 +272,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; }
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
@@ -260,7 +290,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}
}, [isVisible]);
// Ensure a fit when split mode toggles to account for new pane geometry
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
@@ -271,7 +300,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, [splitScreen]);
return (
<div ref={xtermRef} className="h-full w-full m-1" style={{ opacity: visible && isVisible ? 1 : 0, overflow: 'hidden' }} />
<div ref={xtermRef} className="h-full w-full m-1"
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
);
});

View File

@@ -49,7 +49,6 @@ interface SSHTunnelProps {
}
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
// Keep full list for endpoint lookups; keep a separate visible list for UI
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
@@ -86,7 +85,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
})
: hostsData;
// Silent update: only set state if meaningful changes
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
@@ -120,7 +118,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
const handleHostsChanged = () => {
fetchHosts();
};

View File

@@ -76,7 +76,7 @@ interface SSHTunnelObjectProps {
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean; // when true, render without Card wrapper/background
bare?: boolean;
}
export function TunnelObject({
@@ -168,7 +168,6 @@ export function TunnelObject({
if (bare) {
return (
<div className="w-full min-w-0">
{/* Tunnel Connections (bare) */}
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
@@ -187,7 +186,6 @@ export function TunnelObject({
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
@@ -203,7 +201,6 @@ export function TunnelObject({
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{/* Action Buttons */}
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
@@ -255,7 +252,6 @@ export function TunnelObject({
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
@@ -280,7 +276,6 @@ export function TunnelObject({
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
@@ -313,7 +308,6 @@ export function TunnelObject({
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{/* Host Header */}
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
@@ -330,7 +324,6 @@ export function TunnelObject({
</div>
)}
{/* Tags */}
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
@@ -349,7 +342,6 @@ export function TunnelObject({
{!compact && <Separator className="mb-3"/>}
{/* Tunnel Connections */}
<div className="space-y-3">
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
@@ -374,7 +366,6 @@ export function TunnelObject({
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
@@ -390,7 +381,6 @@ export function TunnelObject({
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Action Buttons */}
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
@@ -443,7 +433,6 @@ export function TunnelObject({
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
@@ -468,7 +457,6 @@ export function TunnelObject({
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">

View File

@@ -52,7 +52,6 @@ export function TunnelViewer({
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
// Single-host view: use first host if present
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
@@ -60,7 +59,8 @@ export function TunnelViewer({
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
<p className="text-muted-foreground max-w-md">
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
connections.
</p>
</div>
);
@@ -72,7 +72,8 @@ export function TunnelViewer({
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}