diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94d1b15..fd0e78f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Checks on: push: - branches: [ main ] + branches: [ main, dev ] pull_request: - branches: [ main ] + branches: [ main, dev ] env: CARGO_TERM_COLOR: always diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..666ecaa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2026-07-04 + +### Added +- **Zero-Friction Portable Workflow**: Redesigned the onboarding process by completely removing the setup key requirements. Added a welcoming onboarding screen (Step 0) in the browser before configuring the system. +- **Cloudflare Tunnel Auto-Start**: Configured the Cloudflare tunnel to dynamically start immediately upon completing the setup wizard if the user opts in, removing any need for a server restart. +- **User-Friendly Startup Console**: Updated the server CLI banner on launch to show a clean box-drawing layout directing the user to press Enter to instantly minimize the window to system tray and open their default browser. + +### Fixed +- **Windows Console Flashing**: Resolved an issue where multiple empty Command Prompt/PowerShell terminal windows flashed on the screen on Windows during startup and periodic background operations (telemetry WMI polling, network/hardware status checks, registry queries, and Cloudflare tunnel spawning) by forcing child processes to spawn silently with the `CREATE_NO_WINDOW` process creation flag. + +## [1.0.1] - 2026-07-03 + +### Fixed +- **CI/CD Pipeline**: GitHub Actions build and linker failures on macOS and Linux runners. +- **Linux Platform**: Added missing `libxdo-dev` dependency on build environments to fix input compilation/linking. +- **macOS Platform**: Fixed key mapping error by conditionalizing `F21`–`F24` keys, which are not supported by Enigo on macOS. +- **macOS Platform**: Silenced compiler warning for unused `brightness` variable during macOS-specific display operations. +- **Frontend Code Quality**: Resolved all `oxlint` accessibility and linting warnings: + - Cleaned up unused imports and variables across multiple components. + - Linked form controls to labels for better screen reader compatibility. + - Added missing `aria-label` properties to toggle elements. + - Normalized React Hook dependency arrays. +- **Backend Code Quality**: Removed unused imports (`std::io`, `std::ffi::c_void`), cleaned up unused `mut` specifiers, and resolved `Arc` borrow-after-move error in `main.rs`. + +## [1.0.0] - 2026-07-03 + +### Added +- **Core Architecture**: Lightweight remote system administration agent utilizing a Rust backend and embedded React/TypeScript single-page app (SPA). +- **System Telemetry**: Real-time telemetry monitoring including CPU load, RAM usage, network interface speeds, disk usage, and battery capacity. +- **File Manager**: Directory navigation, file downloads, secure path validation blocklists, and streaming uploads supporting files up to 500MB. +- **Terminal Emulator**: Remote interactive terminal interface leveraging PTY and `xterm.js`. +- **Script Engine**: Secure execution of PowerShell (Windows) and Bash (macOS/Linux) scripts with customizable execution timeouts. +- **Power Management**: Support for remote Shutdown, Restart, Sleep, Hibernate, Sign Out, Lock, and Switch User actions with a 5-second grace period cancel queue. +- **Cloudflare Tunnels**: Integrated `cloudflared` tunnel creation for secure remote access without port forwarding. +- **System Tray Widget**: Native system tray application displaying active status, quick tunnel actions, and port configurations. +- **Security & MFA**: Cryptographic password hashing (Argon2id), mandatory TOTP multi-factor authentication setup, recovery code generation, and automated lockout policies. diff --git a/README.md b/README.md index 60583bb..a7b119b 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ On first run, the backend: 2. Stores the JWT signing key in your OS keychain (Credential Manager / Keychain / Secret Service) 3. Downloads the correct `cloudflared` binary for your platform with SHA256 verification 4. Binds to `localhost:3939` (falls back to random port) -5. Opens your browser to the setup wizard +5. Generates a one-time **Setup Key** printed to the console window. This key acts as a security token to verify physical/owner access to the host machine. +6. Opens your browser to the first-run Setup Wizard, where you must enter this Setup Key to unlock configuration steps (admin credentials, TOTP, and remote access relay). ### Production Build @@ -264,7 +265,7 @@ sysdeck/ | POST | `/api/setup/password` | Set initial password (step 1) | | POST | `/api/setup/totp` | Generate TOTP secret (step 2) | | POST | `/api/setup/verify-totp` | Verify TOTP code (step 3) | -| POST | `/api/setup/recovery-codes` | Generate recovery codes (step 4) | +| POST | `/api/setup/relay` | Set Cloudflare relay opt-in (step 4) | | POST | `/api/setup/finish` | Complete setup | ### Dashboard & Telemetry diff --git a/backend/src/hardware.rs b/backend/src/hardware.rs index d0e7004..8cbe263 100644 --- a/backend/src/hardware.rs +++ b/backend/src/hardware.rs @@ -2,7 +2,6 @@ use axum::extract::State; use axum::response::{IntoResponse, Json}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::process::Command; use std::sync::atomic::Ordering; use std::time::Duration; @@ -82,7 +81,7 @@ pub struct ScheduledPowerRequest { #[allow(dead_code)] fn run_cmd(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd) + let output = crate::new_command(cmd) .args(args) .output() .map_err(|e| format!("Failed to execute {}: {}", cmd, e))?; @@ -99,7 +98,7 @@ fn run_powershell(script: &str) -> Result { use std::io::Write; use std::process::Stdio; - let mut child = Command::new("powershell") + let mut child = crate::new_command("powershell") .args(["-NoProfile", "-Command", "-"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -1130,7 +1129,7 @@ pub async fn schedule_power_handler( crate::power::PowerAction::Restart => "/r", _ => "/s", }; - let _ = Command::new("shutdown") + let _ = crate::new_command("shutdown") .args([flag, "/f", "/t", "1"]) .spawn(); } else { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 3e0c408..b884cdb 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -76,7 +76,33 @@ pub struct AppState { pub terminal_state: Arc, pub tunnel_state: Arc, pub port: u16, - pub setup_token: Arc, +} + +pub fn new_command>(program: S) -> std::process::Command { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let mut cmd = std::process::Command::new(program); + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + cmd + } + #[cfg(not(target_os = "windows"))] + { + std::process::Command::new(program) + } +} + +pub fn new_tokio_command>(program: S) -> tokio::process::Command { + #[cfg(target_os = "windows")] + { + let mut cmd = tokio::process::Command::new(program); + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + cmd + } + #[cfg(not(target_os = "windows"))] + { + tokio::process::Command::new(program) + } } pub fn get_data_dir() -> PathBuf { @@ -212,7 +238,7 @@ pub async fn find_available_port() -> (u16, tokio::net::TcpListener) { fn is_startup_enabled() -> bool { #[cfg(target_os = "windows")] { - std::process::Command::new("reg") + new_command("reg") .args([ "query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", @@ -234,7 +260,7 @@ fn set_startup(enabled: bool) { .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); if enabled { - let _ = std::process::Command::new("reg") + let _ = new_command("reg") .args([ "add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", @@ -248,7 +274,7 @@ fn set_startup(enabled: bool) { ]) .output(); } else { - let _ = std::process::Command::new("reg") + let _ = new_command("reg") .args([ "delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", diff --git a/backend/src/main.rs b/backend/src/main.rs index 91c53e2..435f05b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -5,7 +5,6 @@ use std::sync::Arc; -use rand::Rng; use tokio::sync::{broadcast, oneshot, Mutex}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -202,14 +201,6 @@ async fn main() { let jwt_key = Arc::new(auth::load_or_create_jwt_key().expect("Failed to load JWT signing key")); println!(" ✓ JWT signing key loaded"); - // Generate setup token for headless/remote setup - let setup_token: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(16) - .map(char::from) - .collect(); - let setup_token = Arc::new(setup_token); - let (port, listener) = find_available_port().await; println!(" ✓ Server bound to localhost:{}", port); @@ -348,7 +339,6 @@ async fn main() { terminal_state, tunnel_state: tunnel_state.clone(), port, - setup_token: setup_token.clone(), }; let app = build_router(app_state.clone()); @@ -411,49 +401,37 @@ async fn main() { server_ready_rx.await.ok(); - // ── Startup banner (complete) ──────────────────────────────────────────── + // ── Startup banner ──────────────────────────────────────────── println!(); - println!(" ──────────────────────────────────────────────────"); - println!(" Dashboard → http://localhost:{}", port); - println!(" Manage → System tray icon"); - println!(" Setup key → {}", setup_token); - println!(" ──────────────────────────────────────────────────"); - - let is_headless = cfg!(target_os = "linux") && std::env::var("DISPLAY").is_err(); - if is_headless { - println!(); - println!(" Headless mode — no display detected."); - println!(" Use SSH port forwarding to reach the dashboard:"); - println!(" ssh -L {}:127.0.0.1:{} user@host", port, port); - } else { - println!(); - println!(" Press Enter to open the dashboard."); - println!(" Close this window at any time — SysDeck keeps running."); - } + println!("┌────────────────────────────────────────────────────────┐"); + println!("│ ⚡ SysDeck is running! │"); + println!("├────────────────────────────────────────────────────────┤"); + println!("│ The app is now active in your system tray. │"); + println!("│ │"); + println!("│ Press [Enter] to open the setup wizard in your browser│"); + println!("│ Press [Ctrl+C] to stop the app │"); + println!("└────────────────────────────────────────────────────────┘"); println!(); - // ──────────────────────────────────────────────────────────────────────── // Spawn a blocking task to wait for Enter, then open the browser and // detach the splash console. On Windows, if the user closes the window // first (CTRL_CLOSE_EVENT), FreeConsole() closes stdin, read_line() // returns EOF, and open_dashboard_once() is a no-op (already opened). - if !is_headless { - tokio::task::spawn_blocking(move || { - let mut buf = String::new(); - // Returns Ok(0) on EOF (stdin closed by FreeConsole) or Ok(n) on Enter. - let _ = std::io::stdin().read_line(&mut buf); - - #[cfg(windows)] - { - detach_splash_console(); - open_dashboard_once(); - } - #[cfg(not(windows))] - { - let _ = open::that(format!("http://localhost:{}", port)); - } - }); - } + tokio::task::spawn_blocking(move || { + let mut buf = String::new(); + // Returns Ok(0) on EOF (stdin closed by FreeConsole) or Ok(n) on Enter. + let _ = std::io::stdin().read_line(&mut buf); + + #[cfg(windows)] + { + detach_splash_console(); + open_dashboard_once(); + } + #[cfg(not(windows))] + { + let _ = open::that(format!("http://localhost:{}", port)); + } + }); server_handle.await.expect("Server task panicked"); diff --git a/backend/src/network.rs b/backend/src/network.rs index bde8b82..8eef1c8 100644 --- a/backend/src/network.rs +++ b/backend/src/network.rs @@ -1,7 +1,6 @@ use axum::response::{IntoResponse, Json}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::process::Command; use tracing; #[derive(Serialize, Debug)] @@ -51,7 +50,7 @@ fn default_wifi_security() -> String { } fn run_cmd(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd) + let output = crate::new_command(cmd) .args(args) .output() .map_err(|e| format!("Failed to execute {}: {}", cmd, e))?; @@ -66,7 +65,7 @@ fn run_cmd(cmd: &str, args: &[&str]) -> Result { fn run_powershell(script: &str) -> Result { use std::io::Write; use std::process::Stdio; - let mut child = Command::new("powershell") + let mut child = crate::new_command("powershell") .args(["-NoProfile", "-Command", "-"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/backend/src/power.rs b/backend/src/power.rs index 239bdb0..39c9f9d 100644 --- a/backend/src/power.rs +++ b/backend/src/power.rs @@ -31,73 +31,63 @@ impl SystemCommands for RealOs { #[cfg(target_os = "windows")] match action { PowerAction::Shutdown => { - let _ = std::process::Command::new("shutdown") + let _ = crate::new_command("shutdown") .args(["/s", "/t", "1"]) .spawn(); } PowerAction::Restart => { - let _ = std::process::Command::new("shutdown") + let _ = crate::new_command("shutdown") .args(["/r", "/t", "1"]) .spawn(); } PowerAction::Sleep => { - let _ = std::process::Command::new("rundll32.exe") + let _ = crate::new_command("rundll32.exe") .args(["powrprof.dll,SetSuspendState", "0", "1", "0"]) .spawn(); } PowerAction::Hibernate => { - let _ = std::process::Command::new("rundll32.exe") + let _ = crate::new_command("rundll32.exe") .args(["powrprof.dll,SetSuspendState", "1", "1", "0"]) .spawn(); } PowerAction::SignOut => { - let _ = std::process::Command::new("shutdown").args(["/l"]).spawn(); + let _ = crate::new_command("shutdown").args(["/l"]).spawn(); } PowerAction::Lock => { - let _ = std::process::Command::new("rundll32.exe") + let _ = crate::new_command("rundll32.exe") .args(["user32.dll,LockWorkStation"]) .spawn(); } PowerAction::SwitchUser => { // Switch to the login screen to allow another user to log in - let _ = std::process::Command::new("tsdiscon.exe").spawn(); + let _ = crate::new_command("tsdiscon.exe").spawn(); } } #[cfg(target_os = "linux")] match action { PowerAction::Shutdown => { - let _ = std::process::Command::new("systemctl") - .arg("poweroff") - .spawn(); + let _ = crate::new_command("systemctl").arg("poweroff").spawn(); } PowerAction::Restart => { - let _ = std::process::Command::new("systemctl") - .arg("reboot") - .spawn(); + let _ = crate::new_command("systemctl").arg("reboot").spawn(); } PowerAction::Sleep => { - let _ = std::process::Command::new("systemctl") - .arg("suspend") - .spawn(); + let _ = crate::new_command("systemctl").arg("suspend").spawn(); } PowerAction::Hibernate => { - let _ = std::process::Command::new("systemctl") - .arg("hibernate") - .spawn(); + let _ = crate::new_command("systemctl").arg("hibernate").spawn(); } PowerAction::SignOut => { - let _ = std::process::Command::new("loginctl") + let _ = crate::new_command("loginctl") .args(["terminate-session", "self"]) .spawn(); } PowerAction::Lock => { - let _ = std::process::Command::new("loginctl") - .arg("lock-session") - .spawn(); + let _ = crate::new_command("loginctl").arg("lock-session").spawn(); } PowerAction::SwitchUser => { // Switch user via display manager - let _ = std::process::Command::new("dm-tool") + let _ = crate::new_command("dm-tool") .arg("switch-to-greeter") .spawn(); } @@ -105,41 +95,41 @@ impl SystemCommands for RealOs { #[cfg(target_os = "macos")] match action { PowerAction::Shutdown => { - let _ = std::process::Command::new("osascript") + let _ = crate::new_command("osascript") .args(["-e", "tell application \"System Events\" to shut down"]) .spawn(); } PowerAction::Restart => { - let _ = std::process::Command::new("osascript") + let _ = crate::new_command("osascript") .args(["-e", "tell application \"System Events\" to restart"]) .spawn(); } PowerAction::Sleep => { - let _ = std::process::Command::new("osascript") + let _ = crate::new_command("osascript") .args(["-e", "tell application \"System Events\" to sleep"]) .spawn(); } PowerAction::Hibernate => { // macOS: enable hibernate mode then sleep - let _ = std::process::Command::new("pmset") + let _ = crate::new_command("pmset") .args(["-a", "hibernatemode", "25"]) .spawn(); - let _ = std::process::Command::new("osascript") + let _ = crate::new_command("osascript") .args(["-e", "tell application \"System Events\" to sleep"]) .spawn(); } PowerAction::SignOut => { - let _ = std::process::Command::new("osascript") + let _ = crate::new_command("osascript") .args(["-e", "tell application \"System Events\" to log out"]) .spawn(); } PowerAction::Lock => { - let _ = std::process::Command::new("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession") + let _ = crate::new_command("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession") .arg("-suspend") .spawn(); } PowerAction::SwitchUser => { - let _ = std::process::Command::new("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession") + let _ = crate::new_command("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession") .arg("-suspend") .spawn(); } diff --git a/backend/src/process.rs b/backend/src/process.rs index 1e8ca26..f31a12f 100644 --- a/backend/src/process.rs +++ b/backend/src/process.rs @@ -48,7 +48,7 @@ pub async fn kill_handler(Json(body): Json) -> impl IntoResponse { #[cfg(target_os = "windows")] { let pid = body.pid; - match std::process::Command::new("taskkill") + match crate::new_command("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .output() { diff --git a/backend/src/script.rs b/backend/src/script.rs index 70ddbc0..5e12a88 100644 --- a/backend/src/script.rs +++ b/backend/src/script.rs @@ -10,7 +10,6 @@ use axum::response::{IntoResponse, Json, Response}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::io::AsyncBufReadExt; -use tokio::process::Command; use tokio::sync::broadcast; use tokio::sync::Mutex; use uuid::Uuid; @@ -19,7 +18,7 @@ fn kill_process_tree(pid: Option) { let Some(pid) = pid else { return }; #[cfg(target_os = "windows")] { - let _ = std::process::Command::new("taskkill") + let _ = crate::new_command("taskkill") .args(["/T", "/F", "/PID", &pid.to_string()]) .spawn() .map(|mut c| { @@ -28,10 +27,10 @@ fn kill_process_tree(pid: Option) { } #[cfg(unix)] { - let _ = std::process::Command::new("pkill") + let _ = crate::new_command("pkill") .args(["-P", &pid.to_string()]) .spawn(); - let _ = std::process::Command::new("kill") + let _ = crate::new_command("kill") .args(["-9", &pid.to_string()]) .spawn(); } @@ -223,7 +222,7 @@ async fn run_script( "$OutputEncoding = [System.Text.Encoding]::UTF8; {}", content ); - Command::new("powershell") + crate::new_tokio_command("powershell") .args(["-NoProfile", "-Command", &ps_content]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -232,7 +231,7 @@ async fn run_script( #[cfg(target_os = "windows")] { let content = content.replace("\r\n", " & ").replace('\n', " & "); - Command::new("cmd") + crate::new_tokio_command("cmd") .args(["/C", &content]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -250,7 +249,7 @@ async fn run_script( #[cfg(target_os = "windows")] { let content = content.replace("\r\n", " & ").replace('\n', " & "); - Command::new("cmd") + crate::new_tokio_command("cmd") .args(["/C", &content]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -258,7 +257,7 @@ async fn run_script( } #[cfg(not(target_os = "windows"))] { - Command::new("bash") + crate::new_tokio_command("bash") .args(["-c", content]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) diff --git a/backend/src/setup.rs b/backend/src/setup.rs index cdb7b57..72806ac 100644 --- a/backend/src/setup.rs +++ b/backend/src/setup.rs @@ -89,6 +89,23 @@ pub async fn api_password_handler( State(state): State, Json(body): Json, ) -> Response { + let needs_setup = { + let conn = state.db.lock().await; + db::is_setup_complete(&conn).map(|c| !c).unwrap_or(true) + }; + + if !needs_setup { + tracing::warn!(handler = "api_password_handler", "setup already complete"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "error": "Setup is already completed" + })), + ) + .into_response(); + } + if body.password.len() < 8 { tracing::warn!(handler = "api_password_handler", "password too short"); return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "error": "Password must be at least 8 characters"}))).into_response(); @@ -292,6 +309,14 @@ pub async fn api_finish_handler( ); let _ = db::wal_checkpoint(&conn); drop(conn); + + if flow.relay_opt_in { + let ts = state.tunnel_state.clone(); + tokio::spawn(async move { + let _ = crate::tunnel::TunnelState::start(ts).await; + }); + } + tracing::info!(handler = "api_finish_handler", "setup complete"); Json(serde_json::json!({"success": true})).into_response() } diff --git a/backend/src/telemetry.rs b/backend/src/telemetry.rs index d3b71e1..6bc8d66 100644 --- a/backend/src/telemetry.rs +++ b/backend/src/telemetry.rs @@ -60,7 +60,7 @@ fn get_wmi_temperatures() -> (Option, Option) { let mut gpu = None; for (source, cmd) in &queries { tracing::debug!(source, "querying WMI thermal zones"); - let output = match std::process::Command::new("powershell") + let output = match crate::new_command("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", cmd]) .output() { diff --git a/backend/src/tunnel.rs b/backend/src/tunnel.rs index 721d0c4..83c8cb4 100644 --- a/backend/src/tunnel.rs +++ b/backend/src/tunnel.rs @@ -9,7 +9,6 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use tokio::fs; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; use tokio::sync::{broadcast, Mutex, RwLock}; use crate::AppState; @@ -260,7 +259,7 @@ async fn run_tunnel_loop( None => return, }; - let mut child = match Command::new(&state.exe_path) + let mut child = match crate::new_tokio_command(&state.exe_path) .args([ "tunnel", "--no-autoupdate", diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 411b02b..73b332a 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -90,7 +90,6 @@ fn test_app_inner( terminal_state: Arc::new(TerminalState::default()), tunnel_state: Arc::new(tunnel_state), port: 3939, - setup_token: Arc::new("test-setup-token-123".to_string()), }; let router = sysdeck_agent::build_router(app_state.clone()); diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx index 67e93fb..38d46dd 100644 --- a/frontend/src/pages/Setup.tsx +++ b/frontend/src/pages/Setup.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useNavigate } from "react-router-dom" -import { Check, Copy, Download, Eye, EyeOff, ShieldAlert } from "lucide-react" +import { Check, Copy, Download, Eye, EyeOff } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" @@ -347,6 +347,11 @@ function StepRelay({ onComplete }: { onComplete: (enabled: boolean) => void }) { Optionally expose this agent to the internet via a Cloudflare Quick Tunnel. This allows remote access from outside your local network without port forwarding.

+
+ + *Note: Because this uses a free, zero-signup tunnel, your remote URL will change every time the app restarts. We are working on adding persistent domain support soon! + +
void }) { ) } -function StepToken({ onComplete }: { onComplete: () => void }) { - const [inputToken, setInputToken] = useState("") - const [error, setError] = useState("") - const [loading, setLoading] = useState(false) +function StepWelcome({ onComplete }: { onComplete: () => void }) { + return ( +
+
+
+
+ 1 +
+
+

Secure your account

+

+ You will create an admin password and set up Two-Factor Authentication (2FA) to keep your system safe. +

+
+
- async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError("") - setLoading(true) - try { - const res = await fetch("/api/setup/verify-setup-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: inputToken }), - }) - const data = await res.json() - if (data.success) { - onComplete() - } else { - setError("Invalid setup token. Check the server console for the token.") - } - } catch { - setError("Connection error") - } finally { - setLoading(false) - } - } +
+
+ 2 +
+
+

Enable Remote Access (Optional)

+

+ You can turn on the Cloudflare Relay to access this PC from anywhere in the world. +
+ + *Note: Because this uses a free, zero-signup tunnel, your remote URL will change every time the app restarts. We are working on adding persistent domain support soon! + +

+
+
- return ( -
-
- - This server requires a setup token. Enter the token printed in the server console. -
-
- - setInputToken(e.target.value)} - placeholder="Enter setup token" - required - autoComplete="off" - /> +
+
+ 3 +
+
+

Access your dashboard

+

+ Once setup is complete, you'll be able to manage your files, terminal, and system hardware directly from your browser. +

+
+
- {error &&

{error}

} - -
+
) } @@ -425,10 +428,6 @@ export function SetupPage() { const [step, setStep] = useState(0) const [token, setToken] = useState(null) const [checking, setChecking] = useState(true) - const [tokenRequired, setTokenRequired] = useState(false) - const [offset, setOffset] = useState(0) - - const steps = tokenRequired ? ["Token", ...BASE_STEPS] : BASE_STEPS useEffect(() => { async function checkStatus() { @@ -439,19 +438,13 @@ export function SetupPage() { navigate("/login", { replace: true }) return } - const tokenRes = await fetch("/api/setup/check-token") - const tokenData = await tokenRes.json() - if (tokenData.token_required) { - setTokenRequired(true) - setOffset(1) - } const savedToken = sessionStorage.getItem("setup_token") if (savedToken) { const progressRes = await fetch(`/api/setup/progress?token=${savedToken}`) const progressData = await progressRes.json() if (progressData.success) { setToken(savedToken) - setStep(progressData.current_step - 1 + (tokenData.token_required ? 1 : 0)) + setStep(progressData.current_step) } } } catch { @@ -463,23 +456,19 @@ export function SetupPage() { checkStatus() }, [navigate]) - const handleTokenComplete = useCallback(() => { - setStep(1) - }, []) - const handlePasswordComplete = useCallback((newToken: string) => { setToken(newToken) - setStep(1 + offset) - }, [offset]) + setStep(2) + }, []) const handleTotpComplete = useCallback((newToken: string) => { setToken(newToken) - setStep(2 + offset) - }, [offset]) + setStep(3) + }, []) const handleRecoveryComplete = useCallback(() => { - setStep(3 + offset) - }, [offset]) + setStep(4) + }, []) const handleRelayComplete = useCallback(async (enabled: boolean) => { const token = sessionStorage.getItem("setup_token") @@ -511,8 +500,6 @@ export function SetupPage() { ) } - const baseStep = step - offset - return (
@@ -520,28 +507,30 @@ export function SetupPage() {

SysDeck Agent Setup

Configure your agent

- + {step > 0 && } - {steps[step]} + + {step === 0 ? "👋 Welcome to SysDeck" : BASE_STEPS[step - 1]} + - {steps[step] === "Token" ? "Enter the setup token from the server console" : - steps[step] === "Password" ? "Create a strong password to secure your agent" : - steps[step] === "Two-Factor Auth" ? "Set up two-factor authentication" : - steps[step] === "Recovery Codes" ? "Store your recovery codes safely" : + {step === 0 ? "Let's get your remote access set up in 3 quick steps." : + step === 1 ? "Create a strong password to secure your agent" : + step === 2 ? "Set up two-factor authentication" : + step === 3 ? "Store your recovery codes safely" : "Configure remote access"} - {step === 0 && tokenRequired ? ( - - ) : baseStep === 0 ? ( + {step === 0 ? ( + setStep(1)} /> + ) : step === 1 ? ( - ) : baseStep === 1 && token ? ( + ) : step === 2 && token ? ( - ) : baseStep === 2 ? ( + ) : step === 3 ? ( - ) : baseStep === 3 ? ( + ) : step === 4 ? ( ) : null}