index.php
39.3 KB
<?php
/**
* WorkersPanel Installer — v5.1.0
* Design: DM Sans / dark theme (user preferred)
* Logic: Correct paths, full company info, logo saving
*/
define('BASE_PATH', dirname(__DIR__));
require_once __DIR__ . '/../config/version.php';
require_once __DIR__ . '/../config/app_paths.php';
if (file_exists(__DIR__ . '/../install.lock')) {
app_redirect('login');
}
if (session_status() === PHP_SESSION_NONE) {
if (!headers_sent()) {
$installerHost = strtolower((string)($_SERVER['HTTP_HOST'] ?? 'localhost'));
$installerBase = function_exists('app_base_path') ? app_base_path() : '';
$installerReal = realpath(BASE_PATH) ?: BASE_PATH;
$installerKey = substr(hash('sha256', $installerHost . '|' . $installerBase . '|' . $installerReal . '|install'), 0, 16);
session_name('WPINST_' . strtoupper($installerKey));
session_set_cookie_params([
'lifetime' => 0,
'path' => $installerBase !== '' ? rtrim($installerBase, '/') . '/' : '/',
'domain' => '',
'secure' => (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') || (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'),
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_start();
}
// ── SQL HELPERS (installer migrations) ─────────────────────────────────
function wp_split_sql_statements(string $sql): array {
$sql = str_replace("\r\n", "\n", $sql);
$stmts = [];
$buf = '';
$inSingle = false;
$inDouble = false;
$inBacktick = false;
$escape = false;
$len = strlen($sql);
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($escape) {
$buf .= $ch;
$escape = false;
continue;
}
if (($inSingle || $inDouble) && $ch === "\\") {
$buf .= $ch;
$escape = true;
continue;
}
if (!$inDouble && !$inBacktick && $ch === "'") { $inSingle = !$inSingle; $buf .= $ch; continue; }
if (!$inSingle && !$inBacktick && $ch === '"') { $inDouble = !$inDouble; $buf .= $ch; continue; }
if (!$inSingle && !$inDouble && $ch === '`') { $inBacktick = !$inBacktick; $buf .= $ch; continue; }
if (!$inSingle && !$inDouble && !$inBacktick && $ch === ';') {
$stmt = trim($buf);
if ($stmt !== '') $stmts[] = $stmt;
$buf = '';
continue;
}
$buf .= $ch;
}
$tail = trim($buf);
if ($tail !== '') $stmts[] = $tail;
return $stmts;
}
function wp_run_sql_file(PDO $pdo, string $path): void {
$sql = @file_get_contents($path);
if ($sql === false) return;
foreach (wp_split_sql_statements($sql) as $stmt) {
if ($stmt !== '') $pdo->exec($stmt);
}
}
function wp_db_table_exists(PDO $pdo, string $table): bool {
try {
$stmt = $pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1");
$stmt->execute([$table]);
return (bool)$stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
function wp_detect_existing_admin(PDO $pdo): ?array {
if (!wp_db_table_exists($pdo, 'users')) {
return null;
}
try {
$stmt = $pdo->query("
SELECT id, username, email, display_name, role, is_active
FROM users
WHERE LOWER(REPLACE(REPLACE(TRIM(role), '_', '-'), ' ', '-')) IN ('administrator', 'director', 'admin', 'super-admin', 'superadmin')
AND is_active = 1
ORDER BY id ASC
LIMIT 1
");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
function wp_get_system_info(PDO $pdo): array {
if (!wp_db_table_exists($pdo, 'system_info')) {
return [];
}
try {
$stmt = $pdo->query("SELECT `key`, `value` FROM system_info");
$rows = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
return is_array($rows) ? $rows : [];
} catch (Throwable $e) {
return [];
}
}
function wp_detect_existing_company_info(PDO $pdo): ?array {
$info = wp_get_system_info($pdo);
if (empty($info)) {
return null;
}
$companyKeys = [
'company_name',
'company_website',
'company_email',
'company_phone',
'company_address',
'logo_path',
'favicon_path',
];
$hasMeaningfulValue = false;
foreach ($companyKeys as $key) {
if (!array_key_exists($key, $info)) {
continue;
}
$value = trim((string)($info[$key] ?? ''));
if ($value === '') {
continue;
}
if ($key === 'company_name' && in_array(strtolower($value), ['workerspanel','company name'], true)) {
$hasMeaningfulValue = true;
continue;
}
$hasMeaningfulValue = true;
}
if (!$hasMeaningfulValue && !array_key_exists('company_name', $info)) {
return null;
}
return [
'company_name' => (string)($info['company_name'] ?? ''),
'company_website' => (string)($info['company_website'] ?? ''),
'company_email' => (string)($info['company_email'] ?? ''),
'company_phone' => (string)($info['company_phone'] ?? ''),
'company_address' => (string)($info['company_address'] ?? ''),
'logo_path' => (string)($info['logo_path'] ?? ''),
'favicon_path' => (string)($info['favicon_path'] ?? ''),
'installed_at' => (string)($info['installed_at'] ?? ''),
];
}
$step = (int)($_GET['step'] ?? 1);
$errors = [];
if ($step === 2 && !empty($_SESSION['existing_admin_found'])) {
header('Location: ?step=' . (!empty($_SESSION['existing_company_found']) ? '4' : '3'));
exit;
}
if ($step === 3 && !empty($_SESSION['existing_company_found'])) {
header('Location: ?step=4');
exit;
}
// ── STEP 1: Database ─────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 1) {
$_SESSION['db_host'] = trim($_POST['db_host'] ?? '');
$_SESSION['db_port'] = trim($_POST['db_port'] ?? '3306');
$_SESSION['db_name'] = trim($_POST['db_name'] ?? '');
$_SESSION['db_user'] = trim($_POST['db_user'] ?? '');
$_SESSION['db_pass'] = (string)($_POST['db_pass'] ?? '');
if (!$_SESSION['db_host']) $errors[] = 'Database host is required';
if (!$_SESSION['db_name']) $errors[] = 'Database name is required';
if (!$_SESSION['db_user']) $errors[] = 'Database username is required';
if (empty($errors)) {
try {
$pdo = new PDO(
"mysql:host={$_SESSION['db_host']};port={$_SESSION['db_port']};charset=utf8mb4",
$_SESSION['db_user'], $_SESSION['db_pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$_SESSION['db_name']}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
// Write config/database.php
$h = var_export($_SESSION['db_host'], true);
$p = var_export($_SESSION['db_port'], true);
$n = var_export($_SESSION['db_name'], true);
$u = var_export($_SESSION['db_user'], true);
$w = var_export($_SESSION['db_pass'], true);
$cfg = "<?php\n// Auto-generated by WorkersPanel installer\n"
. "\$db_host={$h};\n\$db_port={$p};\n\$db_name={$n};\n\$db_user={$u};\n\$db_pass={$w};\n"
. "\$dsn=\"mysql:host=\".{$h}.\";port=\".{$p}.\";dbname=\".{$n}.\";charset=utf8mb4\";\n"
. "\$pdo=new PDO(\$dsn,\$db_user,\$db_pass,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);\n";
if (file_put_contents(__DIR__ . '/../config/database.php', $cfg) === false) {
$errors[] = 'Could not write config/database.php — check folder permissions.';
} else {
$_SESSION['db_ok'] = true;
$targetPdo = new PDO(
"mysql:host={$_SESSION['db_host']};port={$_SESSION['db_port']};dbname={$_SESSION['db_name']};charset=utf8mb4",
$_SESSION['db_user'], $_SESSION['db_pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$existingAdmin = wp_detect_existing_admin($targetPdo);
if ($existingAdmin) {
$_SESSION['existing_admin_found'] = true;
$_SESSION['existing_admin_user'] = [
'id' => (int)$existingAdmin['id'],
'username' => (string)$existingAdmin['username'],
'email' => (string)$existingAdmin['email'],
'display_name' => (string)$existingAdmin['display_name'],
'role' => (string)$existingAdmin['role'],
];
} else {
unset($_SESSION['existing_admin_found'], $_SESSION['existing_admin_user']);
}
$existingCompany = wp_detect_existing_company_info($targetPdo);
if ($existingCompany) {
$_SESSION['existing_company_found'] = true;
$_SESSION['existing_company_info'] = $existingCompany;
$_SESSION['company_name'] = (string)($existingCompany['company_name'] ?? '');
$_SESSION['company_website'] = (string)($existingCompany['company_website'] ?? '');
$_SESSION['company_email'] = (string)($existingCompany['company_email'] ?? '');
$_SESSION['company_phone'] = (string)($existingCompany['company_phone'] ?? '');
$_SESSION['company_address'] = (string)($existingCompany['company_address'] ?? '');
if (!empty($existingCompany['logo_path'])) {
$_SESSION['logo_path'] = (string)$existingCompany['logo_path'];
}
if (!empty($existingCompany['favicon_path'])) {
$_SESSION['favicon_path'] = (string)$existingCompany['favicon_path'];
}
} else {
unset($_SESSION['existing_company_found'], $_SESSION['existing_company_info']);
unset($_SESSION['company_name'], $_SESSION['company_website'], $_SESSION['company_email'], $_SESSION['company_phone'], $_SESSION['company_address'], $_SESSION['logo_path'], $_SESSION['favicon_path']);
}
if (!empty($_SESSION['existing_admin_found']) && !empty($_SESSION['existing_company_found'])) {
header('Location: ?step=4');
exit;
}
if (!empty($_SESSION['existing_admin_found'])) {
header('Location: ?step=3');
exit;
}
header('Location: ?step=2');
exit;
}
} catch (Throwable $e) {
$errors[] = 'Connection failed: ' . $e->getMessage();
}
}
}
// ── STEP 2: Admin Account ────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 2) {
$_SESSION['admin_username'] = trim($_POST['admin_username'] ?? '');
$_SESSION['admin_email'] = trim($_POST['admin_email'] ?? '');
$_SESSION['admin_display_name'] = trim($_POST['admin_display_name'] ?? '');
$_SESSION['admin_password'] = (string)($_POST['admin_password'] ?? '');
$confirm = (string)($_POST['admin_password_confirm'] ?? '');
if (!$_SESSION['admin_username']) $errors[] = 'Username is required';
if (!filter_var($_SESSION['admin_email'], FILTER_VALIDATE_EMAIL)) $errors[] = 'Valid email is required';
if (!$_SESSION['admin_display_name']) $errors[] = 'Display name is required';
if (strlen($_SESSION['admin_password']) < 8) $errors[] = 'Password must be at least 8 characters';
if ($_SESSION['admin_password'] !== $confirm) $errors[] = 'Passwords do not match';
if (empty($errors)) { header('Location: ?step=3'); exit; }
}
// ── STEP 3: Company Info + Logo ──────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 3) {
$_SESSION['company_name'] = trim($_POST['company_name'] ?? '');
$_SESSION['company_website'] = trim($_POST['company_website'] ?? '');
$_SESSION['company_email'] = trim($_POST['company_email'] ?? '');
$_SESSION['company_phone'] = trim($_POST['company_phone'] ?? '');
$_SESSION['company_address'] = trim($_POST['company_address'] ?? '');
if (!$_SESSION['company_name']) $errors[] = 'Company name is required';
// Logo upload
if (!empty($_FILES['company_logo']['tmp_name']) && $_FILES['company_logo']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['company_logo'];
$allowed = ['image/png','image/jpeg','image/jpg','image/gif','image/webp','image/svg+xml'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!in_array($mime, $allowed, true)) {
$errors[] = 'Logo must be PNG, JPG, GIF, WebP or SVG';
} elseif ($file['size'] > 2 * 1024 * 1024) {
$errors[] = 'Logo must be under 2MB';
} else {
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$destDir = __DIR__ . '/../assets/images/';
if (!is_dir($destDir)) @mkdir($destDir, 0755, true);
// Only remove existing logo files when the installer receives a new logo.
foreach (glob($destDir . 'logo.*') as $old) @unlink($old);
if (!move_uploaded_file($file['tmp_name'], $destDir . 'logo.' . $ext)) {
$errors[] = 'Could not save logo — check assets/images/ permissions';
} else {
$_SESSION['logo_path'] = '/assets/images/logo.' . $ext;
}
}
}
// Favicon upload
if (!empty($_FILES['company_favicon']['tmp_name']) && $_FILES['company_favicon']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['company_favicon'];
$allowed = ['image/png','image/jpeg','image/jpg','image/gif','image/webp','image/svg+xml','image/x-icon','image/vnd.microsoft.icon'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!in_array($mime, $allowed, true)) {
$errors[] = 'Favicon must be PNG, JPG, GIF, WebP, SVG or ICO';
} elseif ($file['size'] > 600 * 1024) {
$errors[] = 'Favicon must be under 600KB';
} else {
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($mime === 'image/x-icon' || $mime === 'image/vnd.microsoft.icon') $ext = 'ico';
$destDir = __DIR__ . '/../assets/images/';
if (!is_dir($destDir)) @mkdir($destDir, 0755, true);
// Only remove existing favicon files when the installer receives a new favicon.
foreach (glob($destDir . 'favicon.*') as $old) @unlink($old);
if (!move_uploaded_file($file['tmp_name'], $destDir . 'favicon.' . $ext)) {
$errors[] = 'Could not save favicon — check assets/images/ permissions';
} else {
$_SESSION['favicon_path'] = '/assets/images/favicon.' . $ext;
}
}
}
if (empty($errors)) { header('Location: ?step=4'); exit; }
}
// ── STEP 4: Run Installation ─────────────────────────────────
$installSuccess = false;
if ($step === 4 && !empty($_SESSION['db_ok'])) {
try {
$pdo = new PDO(
"mysql:host={$_SESSION['db_host']};port={$_SESSION['db_port']};dbname={$_SESSION['db_name']};charset=utf8mb4",
$_SESSION['db_user'], $_SESSION['db_pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Run schema
wp_run_sql_file($pdo, __DIR__ . '/schema.sql');
// Run root migrations (package-level, not stored in app after install)
$rootMigs = realpath(__DIR__ . '/../../migrations');
if ($rootMigs && is_dir($rootMigs)) {
$pdo->exec("CREATE TABLE IF NOT EXISTS system_migrations (id INT AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY filename (filename))");
$files = array_values(array_filter(scandir($rootMigs), function($f){ return str_ends_with($f, '.sql'); }));
sort($files);
foreach ($files as $f) {
$c = $pdo->prepare("SELECT id FROM system_migrations WHERE filename = ?");
$c->execute([$f]);
if ($c->fetch()) continue;
wp_run_sql_file($pdo, $rootMigs . '/' . $f);
$pdo->prepare("INSERT INTO system_migrations (filename) VALUES (?)")->execute([$f]);
}
}
// Save all system_info — matches live DB columns
$existingSystemInfo = wp_get_system_info($pdo);
$logoPath = $_SESSION['logo_path']
?? ($existingSystemInfo['logo_path'] ?? '/assets/images/logo.png');
$faviconPath = $_SESSION['favicon_path']
?? ($existingSystemInfo['favicon_path'] ?? '');
$sysRows = [
'version' => APP_VERSION,
'company_name' => $_SESSION['company_name'] ?? ($existingSystemInfo['company_name'] ?? 'Company Name'),
'company_website' => $_SESSION['company_website'] ?? ($existingSystemInfo['company_website'] ?? ''),
'company_email' => $_SESSION['company_email'] ?? ($existingSystemInfo['company_email'] ?? ''),
'company_phone' => $_SESSION['company_phone'] ?? ($existingSystemInfo['company_phone'] ?? ''),
'company_address' => $_SESSION['company_address'] ?? ($existingSystemInfo['company_address'] ?? ''),
'logo_path' => $logoPath,
'favicon_path' => $faviconPath,
'timezone' => $existingSystemInfo['timezone'] ?? 'UTC',
'date_format' => $existingSystemInfo['date_format'] ?? 'd/m/Y',
'time_format' => $existingSystemInfo['time_format'] ?? 'H:i',
'installed_at' => $existingSystemInfo['installed_at'] ?? date('Y-m-d H:i:s'),
];
$save = $pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)");
foreach ($sysRows as $k => $v) $save->execute([$k, $v]);
// Create admin user only when one does not already exist
if (empty($_SESSION['existing_admin_found'])) {
$pdo->prepare("
INSERT INTO users (username, email, display_name, password_hash, role, is_active, must_change_password)
VALUES (?, ?, ?, ?, 'administrator', 1, 0)
ON DUPLICATE KEY UPDATE
email=VALUES(email), display_name=VALUES(display_name),
password_hash=VALUES(password_hash), role='administrator', is_active=1
")->execute([
$_SESSION['admin_username'],
$_SESSION['admin_email'],
$_SESSION['admin_display_name'],
password_hash($_SESSION['admin_password'], PASSWORD_DEFAULT)
]);
} elseif (!empty($_SESSION['existing_admin_user']['id'])) {
$pdo->prepare("UPDATE users SET is_active = 1 WHERE id = ?")->execute([(int)$_SESSION['existing_admin_user']['id']]);
}
// Write install.lock
file_put_contents(__DIR__ . '/../install.lock', date('c') . "\n" . APP_VERSION . "\n");
// Remove package migrations after install (they are not kept on disk)
$rootMigsPath = realpath(__DIR__ . '/../../migrations');
if ($rootMigsPath && is_dir($rootMigsPath) && basename($rootMigsPath) === 'migrations') {
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($rootMigsPath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $it) { $it->isDir() ? @rmdir($it->getPathname()) : @unlink($it->getPathname()); }
@rmdir($rootMigsPath);
}
$installSuccess = true;
session_regenerate_id(true);
} catch (Throwable $e) {
$errors[] = 'Installation failed: ' . $e->getMessage();
}
}
$stepTitles = [1 => 'Database', 2 => 'Admin Account', 3 => 'Company & Logo', 4 => 'Install'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WorkersPanel Installer</title>
<?php
$logoFiles = glob(__DIR__ . '/../assets/images/logo.*') ?: [];
$faviconFiles = glob(__DIR__ . '/../assets/images/favicon.*') ?: [];
$faviconSrc = '';
if (!empty($faviconFiles)) {
$favFile = basename($faviconFiles[0]);
$favDisk = __DIR__ . '/../assets/images/' . $favFile;
$favVer = @filemtime($favDisk) ?: time();
$faviconSrc = app_asset_url('images/' . $favFile) . '?v=' . $favVer;
} elseif (!empty($logoFiles)) {
$logoFile = basename($logoFiles[0]);
$logoDisk = __DIR__ . '/../assets/images/' . $logoFile;
$logoVer = @filemtime($logoDisk) ?: time();
$faviconSrc = app_asset_url('images/' . $logoFile) . '?v=' . $logoVer;
}
if ($faviconSrc) {
echo '<link rel="icon" href="' . htmlspecialchars($faviconSrc, ENT_QUOTES) . '">';
echo '<link rel="apple-touch-icon" href="' . htmlspecialchars($faviconSrc, ENT_QUOTES) . '">';
}
?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0c0c0e;--surface:#141417;--surface2:#1c1c21;--border:rgba(255,255,255,.08);
--border2:rgba(255,255,255,.14);--accent:#ff8c1a;--accent2:#ffae5c;
--text:#f0f0f2;--muted:#7a7a8a;--error-bg:rgba(255,60,60,.08);--error:#ff6060;
--success:#4cde80;--r:12px;--r2:8px;
}
html,body{min-height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;line-height:1.6}
body{display:flex;align-items:center;justify-content:center;padding:24px;
background-image:radial-gradient(ellipse 60% 40% at 70% 20%,rgba(255,140,26,.07) 0%,transparent 60%),
radial-gradient(ellipse 50% 50% at 20% 80%,rgba(255,140,26,.04) 0%,transparent 60%)}
.installer{width:100%;max-width:520px}
.inst-header{text-align:center;margin-bottom:32px}
.logo-mark{display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;
background:linear-gradient(135deg,var(--accent),#e06600);border-radius:14px;margin-bottom:14px;
box-shadow:0 8px 32px rgba(255,140,26,.25);overflow:hidden}
.logo-mark img{width:100%;height:100%;object-fit:contain;padding:8px}
.logo-mark .logo-fallback{font-size:22px}
.inst-header h1{font-size:22px;font-weight:600;letter-spacing:-.3px}
.inst-header .ver{font-size:12px;color:var(--muted);font-family:'DM Mono',monospace;margin-top:3px}
.steps-bar{display:flex;align-items:center;margin-bottom:28px}
.snode{display:flex;flex-direction:column;align-items:center;gap:5px;flex:1;position:relative}
.snode:not(:last-child)::after{content:'';position:absolute;top:15px;left:calc(50% + 15px);right:calc(-50% + 15px);height:1px;background:var(--border2)}
.snode.done:not(:last-child)::after{background:var(--accent)}
.scircle{width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border2);background:var(--surface);
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;
color:var(--muted);font-family:'DM Mono',monospace;position:relative;z-index:1;transition:all .3s}
.snode.active .scircle{border-color:var(--accent);color:var(--accent);background:rgba(255,140,26,.08);box-shadow:0 0 0 4px rgba(255,140,26,.1)}
.snode.done .scircle{border-color:var(--accent);background:var(--accent);color:#fff;font-size:13px}
.snode.done .snum{display:none}
.snode.done .scircle::before{content:'✓'}
.slabel{font-size:11px;color:var(--muted);font-weight:500;white-space:nowrap}
.snode.active .slabel{color:var(--accent)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:32px;box-shadow:0 8px 48px rgba(0,0,0,.4)}
.ctitle{font-size:17px;font-weight:600;margin-bottom:5px}
.csub{font-size:13px;color:var(--muted);margin-bottom:26px}
.field{margin-bottom:17px}
.frow{display:grid;grid-template-columns:1fr 1fr;gap:14px}
label{display:block;font-size:13px;font-weight:500;margin-bottom:6px}
label .hint{font-weight:400;color:var(--muted);margin-left:3px}
input[type=text],input[type=email],input[type=password],input[type=url],input[type=tel]{
width:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:var(--r2);
padding:10px 14px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px;outline:none;transition:border-color .2s,box-shadow .2s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,140,26,.12)}
.upload-zone{border:1.5px dashed var(--border2);border-radius:var(--r2);padding:24px;text-align:center;
cursor:pointer;transition:all .2s;position:relative;background:var(--surface2)}
.upload-zone:hover,.upload-zone.drag-over{border-color:var(--accent);background:rgba(255,140,26,.04)}
.upload-zone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
.upload-icon{font-size:26px;margin-bottom:7px;opacity:.7}
.upload-zone p{font-size:13px;color:var(--muted)}
.upload-zone p strong{color:var(--accent)}
.logo-prev{margin-top:12px;display:none}
.logo-prev img{max-height:60px;max-width:180px;border-radius:6px}
.errors{background:var(--error-bg);border:1px solid rgba(255,96,96,.25);border-radius:var(--r2);padding:12px 16px;margin-bottom:20px}
.errors p{font-size:13px;color:var(--error);line-height:1.8}
.brow{display:flex;gap:10px;margin-top:26px}
.success-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:26px}
.success-note{margin-top:12px;font-size:12px;color:var(--muted);text-align:center;line-height:1.6}
.success-note a{color:var(--accent);text-decoration:none}
.success-note a:hover{text-decoration:underline}
@media (max-width:560px){.success-actions{grid-template-columns:1fr}}
.btn{flex:1;padding:11px 20px;border-radius:var(--r2);font-family:'DM Sans',sans-serif;font-size:14px;font-weight:600;
border:none;cursor:pointer;transition:all .2s;text-decoration:none;text-align:center;display:inline-block}
.btn-p{background:var(--accent);color:#fff}
.btn-p:hover{background:#e07a14;box-shadow:0 4px 16px rgba(255,140,26,.3);transform:translateY(-1px)}
.btn-g{background:var(--surface2);color:var(--muted);border:1px solid var(--border2);flex:0 0 auto;padding:11px 18px}
.btn-g:hover{color:var(--text)}
.pbar{height:4px;background:var(--surface2);border-radius:4px;overflow:hidden;margin-bottom:18px}
.pfill{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:4px;animation:prog 2s ease forwards}
@keyframes prog{from{width:0}to{width:100%}}
.tasklist{list-style:none}
.tasklist li{font-size:13px;color:var(--muted);padding:6px 0;display:flex;align-items:center;gap:10px;
animation:fadein .4s ease forwards;opacity:0}
.tasklist li:nth-child(1){animation-delay:.3s}.tasklist li:nth-child(2){animation-delay:.8s}
.tasklist li:nth-child(3){animation-delay:1.3s}.tasklist li:nth-child(4){animation-delay:1.8s}
.tasklist li:nth-child(5){animation-delay:2.2s}
@keyframes fadein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}}
.tasklist li::before{content:'✓';width:18px;height:18px;background:rgba(76,222,128,.15);border-radius:50%;
display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--success);flex-shrink:0}
.ok-icon{width:64px;height:64px;background:rgba(76,222,128,.1);border-radius:50%;display:flex;align-items:center;
justify-content:center;margin:0 auto 18px;font-size:28px;animation:popin .5s cubic-bezier(.175,.885,.32,1.275) 1.8s both}
@keyframes popin{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}
.creds{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r2);padding:14px 16px;
margin:14px 0 22px;font-family:'DM Mono',monospace;font-size:13px;line-height:1.9;color:var(--muted)}
.creds strong{color:var(--text)}
.divider{border:none;border-top:1px solid var(--border);margin:18px 0}
footer{text-align:center;margin-top:18px;font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="installer">
<div class="inst-header">
<div class="logo-mark">
<?php
$logoFiles = glob(__DIR__ . '/../assets/images/logo.*') ?: [];
if (!empty($logoFiles)):
$logoSrc = app_asset_url('images/' . basename($logoFiles[0]));
?>
<img src="<?= htmlspecialchars($logoSrc) ?>" alt="Logo">
<?php else: ?>
<span class="logo-fallback">⚡</span>
<?php endif; ?>
</div>
<h1>WorkersPanel</h1>
<div class="ver">v<?= htmlspecialchars(APP_VERSION) ?> — Installer</div>
</div>
<?php if (!$installSuccess): ?>
<div class="steps-bar">
<?php foreach ($stepTitles as $n => $label): ?>
<div class="snode <?= $n < $step ? 'done' : ($n === $step ? 'active' : '') ?>">
<div class="scircle"><span class="snum"><?= $n ?></span></div>
<span class="slabel"><?= $label ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card">
<?php if (!empty($errors)): ?>
<div class="errors"><?php foreach ($errors as $err): ?>
<p>⚠ <?= htmlspecialchars($err) ?></p>
<?php endforeach; ?></div>
<?php endif; ?>
<?php if ($installSuccess): ?>
<!-- ── SUCCESS ── -->
<div class="ok-icon">✓</div>
<div class="ctitle" style="text-align:center">Installation Complete!</div>
<p class="csub" style="text-align:center;margin-bottom:0">WorkersPanel is ready to use</p>
<div class="creds">
<?php $successAdmin = $_SESSION['existing_admin_user'] ?? [
'username' => ($_SESSION['admin_username'] ?? ''),
'email' => ($_SESSION['admin_email'] ?? ''),
'display_name' => ($_SESSION['admin_display_name'] ?? ''),
'role' => 'administrator',
]; ?>
<strong>Username:</strong> <?= htmlspecialchars($successAdmin['username'] ?? '') ?><br>
<strong>Email:</strong> <?= htmlspecialchars($successAdmin['email'] ?? '') ?><br>
<strong>Role:</strong> <?= htmlspecialchars($successAdmin['role'] ?? 'administrator') ?><br>
<strong>Company:</strong> <?= htmlspecialchars($_SESSION['company_name'] ?? ($_SESSION['existing_company_info']['company_name'] ?? '')) ?>
</div>
<?php $appRootUrl = e(app_url('')); $appLoginUrl = e(app_url('login')); $appDirectUrl = e(app_url('index.php')); ?>
<div class="success-actions">
<a href="<?= $appRootUrl ?>" class="btn btn-p">Go to App →</a>
<a href="<?= $appLoginUrl ?>" class="btn btn-g">Go to Login</a>
</div>
<p class="success-note">If the app button does not open your homepage route straight away, use <a href="<?= $appDirectUrl ?>">Direct App Entry</a>.</p>
<?php elseif ($step === 1): ?>
<!-- ── STEP 1: Database ── -->
<div class="ctitle">Database Configuration</div>
<div class="csub">Connect WorkersPanel to your MySQL database</div>
<form method="post" action="?step=1">
<div class="frow">
<div class="field"><label>Host</label><input type="text" name="db_host" value="<?= htmlspecialchars($_SESSION['db_host'] ?? 'localhost') ?>" required></div>
<div class="field"><label>Port</label><input type="text" name="db_port" value="<?= htmlspecialchars($_SESSION['db_port'] ?? '3306') ?>" required></div>
</div>
<div class="field"><label>Database Name</label><input type="text" name="db_name" value="<?= htmlspecialchars($_SESSION['db_name'] ?? '') ?>" placeholder="workerspanel" required></div>
<div class="field"><label>Username</label><input type="text" name="db_user" value="<?= htmlspecialchars($_SESSION['db_user'] ?? '') ?>" required></div>
<div class="field"><label>Password <span class="hint">(leave blank if none)</span></label><input type="password" name="db_pass"></div>
<div class="brow"><button type="submit" class="btn btn-p">Test Connection & Continue →</button></div>
</form>
<?php elseif ($step === 2): ?>
<!-- ── STEP 2: Admin ── -->
<div class="ctitle">Admin Account</div>
<div class="csub">This account will have full administrator access</div>
<form method="post" action="?step=2">
<div class="frow">
<div class="field"><label>Username</label><input type="text" name="admin_username" value="<?= htmlspecialchars($_SESSION['admin_username'] ?? '') ?>" placeholder="admin" required autofocus></div>
<div class="field"><label>Display Name</label><input type="text" name="admin_display_name" value="<?= htmlspecialchars($_SESSION['admin_display_name'] ?? '') ?>" placeholder="John Smith" required></div>
</div>
<div class="field"><label>Email Address</label><input type="email" name="admin_email" value="<?= htmlspecialchars($_SESSION['admin_email'] ?? '') ?>" placeholder="admin@company.com" required></div>
<div class="frow">
<div class="field"><label>Password</label><input type="password" name="admin_password" placeholder="Min. 8 chars" required></div>
<div class="field"><label>Confirm</label><input type="password" name="admin_password_confirm" placeholder="Repeat" required></div>
</div>
<div class="brow">
<a href="?step=1" class="btn btn-g">← Back</a>
<button type="submit" class="btn btn-p">Continue →</button>
</div>
</form>
<?php elseif ($step === 3): ?>
<!-- ── STEP 3: Company + Logo ── -->
<div class="ctitle">Company & Logo</div>
<div class="csub">Shown throughout the app — you can update this any time in Admin → Maintenance</div>
<?php if (!empty($_SESSION['existing_admin_found']) && !empty($_SESSION['existing_admin_user'])): ?>
<div class="creds" style="margin-top:0;margin-bottom:20px">
Existing admin account detected, so step 2 was skipped.<br>
<strong>Username:</strong> <?= htmlspecialchars($_SESSION['existing_admin_user']['username'] ?? '') ?><br>
<strong>Email:</strong> <?= htmlspecialchars($_SESSION['existing_admin_user']['email'] ?? '') ?><br>
<strong>Role:</strong> <?= htmlspecialchars($_SESSION['existing_admin_user']['role'] ?? '') ?>
</div>
<?php endif; ?>
<form method="post" action="?step=3" enctype="multipart/form-data">
<div class="field"><label>Company Name</label>
<input type="text" name="company_name" value="<?= htmlspecialchars($_SESSION['company_name'] ?? '') ?>" placeholder="Company Name" required>
</div>
<div class="frow">
<div class="field"><label>Website <span class="hint">(optional)</span></label>
<input type="url" name="company_website" value="<?= htmlspecialchars($_SESSION['company_website'] ?? '') ?>" placeholder="https://company.com">
</div>
<div class="field"><label>Phone <span class="hint">(optional)</span></label>
<input type="tel" name="company_phone" value="<?= htmlspecialchars($_SESSION['company_phone'] ?? '') ?>" placeholder="+44 7700 000000">
</div>
</div>
<div class="field"><label>Contact Email <span class="hint">(optional)</span></label>
<input type="email" name="company_email" value="<?= htmlspecialchars($_SESSION['company_email'] ?? '') ?>" placeholder="contact@company.com">
</div>
<div class="field"><label>Address <span class="hint">(optional)</span></label>
<input type="text" name="company_address" value="<?= htmlspecialchars($_SESSION['company_address'] ?? '') ?>" placeholder="Company Address">
</div>
<hr class="divider">
<div class="field">
<label>Company Logo <span class="hint">(PNG, JPG, SVG, WebP · max 2MB)</span></label>
<div class="upload-zone" id="zone">
<input type="file" name="company_logo" id="logoInput" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/svg+xml">
<div class="upload-icon">🖼</div>
<p><strong>Click to upload</strong> or drag & drop</p>
<p style="margin-top:4px">Used on the login page, sidebar and installer</p>
<div class="logo-prev" id="prev"><img id="prevImg" src="" alt=""></div>
</div>
</div>
<div class="field">
<label>Browser Favicon <span class="hint">(optional · PNG, ICO, SVG, WebP · max 600KB)</span></label>
<div class="upload-zone" id="favZone">
<input type="file" name="company_favicon" id="favInput" accept=".png,.ico,.svg,.webp,image/png,image/jpeg,image/jpg,image/gif,image/webp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon">
<div class="upload-icon">⭐</div>
<p><strong>Click to upload</strong> or drag & drop</p>
<p style="margin-top:4px">Shown in browser tabs. If left blank, your logo is used.</p>
<div class="logo-prev" id="favPrev"><img id="favPrevImg" src="" alt=""></div>
</div>
</div>
<div class="brow">
<a href="?step=<?= !empty($_SESSION['existing_admin_found']) ? 1 : 2 ?>" class="btn btn-g">← Back</a>
<button type="submit" class="btn btn-p">Install WorkersPanel →</button>
</div>
</form>
<?php else: ?>
<!-- ── STEP 4: Installing ── -->
<div class="ctitle">Installing…</div>
<div class="pbar"><div class="pfill"></div></div>
<ul class="tasklist">
<li>Creating database tables</li>
<li>Running schema migrations</li>
<li><?= !empty($_SESSION['existing_admin_found']) ? 'Keeping existing admin account' : 'Creating admin account' ?></li>
<li>Saving company information</li>
<li>Writing install.lock</li>
</ul>
<?php if (!empty($errors)): ?>
<p style="color:var(--error);font-size:13px;margin-top:14px">Installation failed — see errors above.</p>
<?php else: ?>
<script>setTimeout(()=>location.reload(),400)</script>
<?php endif; ?>
<?php endif; ?>
</div><!-- /card -->
<footer>© <?= date('Y') ?> WorkersPanel <?= htmlspecialchars(APP_VERSION) ?></footer>
</div>
<script>
function bindInstallerUpload(zoneId, inputId, prevId, imgId) {
const zone=document.getElementById(zoneId),input=document.getElementById(inputId),prev=document.getElementById(prevId),img=document.getElementById(imgId);
if(!zone||!input)return;
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over')});
zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');if(e.dataTransfer.files[0]){input.files=e.dataTransfer.files;show(e.dataTransfer.files[0])}});
input.addEventListener('change',()=>{if(input.files[0])show(input.files[0])});
function show(f){if(!f.type.startsWith('image/'))return;const r=new FileReader();r.onload=e=>{img.src=e.target.result;prev.style.display='block'};r.readAsDataURL(f)}
}
bindInstallerUpload('zone','logoInput','prev','prevImg');
bindInstallerUpload('favZone','favInput','favPrev','favPrevImg');
</script>
</body>
</html>