838 lines
26 KiB
PHP
838 lines
26 KiB
PHP
<?php
|
|
// Simple WordOps Panel - vanilla PHP with auth + roles + SQLite
|
|
|
|
// ---------- DB + Auth Helpers ----------
|
|
require_once __DIR__ . '/includes/functions.php';
|
|
|
|
session_start();
|
|
|
|
// Disable PHP output buffering for this script as much as we can
|
|
while (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
ob_implicit_flush(true);
|
|
|
|
// Send headers early
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
header('Cache-Control: no-cache');
|
|
header('X-Accel-Buffering: no'); // Hint for Nginx: disable buffering
|
|
|
|
// Basic config
|
|
$woPath = '/usr/local/bin/wo';
|
|
$bootstrapScript = '/usr/local/bin/wp-dev-bootstrap.sh';
|
|
|
|
// Defaults for bootstrap script
|
|
$bootstrapAdminUser = 'vdidev';
|
|
$bootstrapAdminEmail = 'dev@vincentdevelopment.ca';
|
|
|
|
// Optional theme config for bootstrap script
|
|
$themeStarterRepo = 'git@github.com:Vincent-Design-Inc/VDI-Starter-v5.git';
|
|
$themeRemoteOrigin = ''; // e.g. 'git@github.com:your-org/client-theme-repo.git'
|
|
|
|
// SQLite config
|
|
$dbPath = __DIR__ . '/panel.sqlite';
|
|
|
|
// ---------- Init DB ----------
|
|
|
|
$seedInfo = initDb();
|
|
|
|
// ---------- Routing / Auth Gate ----------
|
|
|
|
$action = isset($_GET['action']) ? $_GET['action'] : 'list';
|
|
|
|
// Logout handler
|
|
if ($action === 'logout') {
|
|
$_SESSION = [];
|
|
session_destroy();
|
|
header('Location: ?action=login');
|
|
exit;
|
|
}
|
|
|
|
requireLogin($action);
|
|
$user = getCurrentUser();
|
|
|
|
// ---------- Login Action ----------
|
|
|
|
if ($action === 'login') {
|
|
$loginError = null;
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$username = trim($_POST['username'] ?? '');
|
|
$password = $_POST['password'] ?? '';
|
|
|
|
if ($username !== '' && $password !== '') {
|
|
$db = getDb();
|
|
$stmt = $db->prepare('SELECT id, username, password_hash, role FROM users WHERE username = ?');
|
|
$stmt->execute([$username]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($row && password_verify($password, $row['password_hash'])) {
|
|
$_SESSION['user_id'] = $row['id'];
|
|
$_SESSION['username'] = $row['username'];
|
|
$_SESSION['role'] = $row['role'];
|
|
|
|
header('Location: ?action=list');
|
|
exit;
|
|
} else {
|
|
$loginError = 'Invalid username or password.';
|
|
}
|
|
} else {
|
|
$loginError = 'Username and password are required.';
|
|
}
|
|
}
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>WordOps Dev Panel - Login</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div>
|
|
<h1 style="margin:0;font-size:1.4rem;">WordOps Dev Panel</h1>
|
|
<p class="muted" style="margin-top:0.25rem;">Sign in to manage dev sites.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="card" style="max-width:420px;margin:2rem auto;">
|
|
<h2 style="margin-top:0;">Login</h2>
|
|
|
|
<?php
|
|
if (!empty($seedInfo['adminCreated'])) {
|
|
echo '<p class="muted"><strong>Initial admin created.</strong><br>Username: <code>admin</code><br>Password: <code>change-me</code>. Please log in and change it later.</p>';
|
|
}
|
|
|
|
if ($loginError) {
|
|
echo '<p><strong>' . htmlspecialchars($loginError) . '</strong></p>';
|
|
}
|
|
?>
|
|
|
|
<form method="post">
|
|
<label>
|
|
Username
|
|
<input type="text" name="username" required>
|
|
</label>
|
|
|
|
<label>
|
|
Password
|
|
<input type="password" name="password" required>
|
|
</label>
|
|
|
|
<div style="margin-top:1rem;">
|
|
<button type="submit" class="btn btn-primary">Login</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
exit;
|
|
}
|
|
|
|
// ---------- Self password change (processed for any authenticated action) ----------
|
|
|
|
$selfPasswordError = null;
|
|
$selfPasswordMessage = null;
|
|
$passwordDialogOpen = false;
|
|
|
|
if (
|
|
$user &&
|
|
$_SERVER['REQUEST_METHOD'] === 'POST' &&
|
|
isset($_POST['op']) &&
|
|
$_POST['op'] === 'self_change_password'
|
|
) {
|
|
$currentPassword = $_POST['current_password'] ?? '';
|
|
$newPassword = $_POST['new_password'] ?? '';
|
|
$newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
|
|
|
|
$passwordDialogOpen = true; // ensure dialog opens after post
|
|
|
|
if ($currentPassword === '' || $newPassword === '' || $newPasswordConfirm === '') {
|
|
$selfPasswordError = 'All password fields are required.';
|
|
} elseif ($newPassword !== $newPasswordConfirm) {
|
|
$selfPasswordError = 'New passwords do not match.';
|
|
} elseif (strlen($newPassword) < 8) {
|
|
$selfPasswordError = 'New password must be at least 8 characters.';
|
|
} else {
|
|
$db = getDb();
|
|
$stmt = $db->prepare('SELECT password_hash FROM users WHERE id = ?');
|
|
$stmt->execute([$user['id']]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row || !password_verify($currentPassword, $row['password_hash'])) {
|
|
$selfPasswordError = 'Current password is incorrect.';
|
|
} else {
|
|
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
|
$update = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
|
|
$update->execute([$hash, $user['id']]);
|
|
$selfPasswordMessage = 'Your password has been updated.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Page Layout (for authenticated users) ----------
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>WordOps Dev Panel</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
|
|
<body>
|
|
<header>
|
|
<div>
|
|
<h1 style="margin:0;font-size:1.4rem;">WordOps Dev Panel</h1>
|
|
<p class="muted" style="margin-top:0.25rem;">Create and manage dev sites.</p>
|
|
</div>
|
|
|
|
<nav style="display:flex;align-items:center;gap:0.75rem;">
|
|
<a href="?action=list" class="btn">Sites</a>
|
|
<a href="?action=create" class="btn btn-primary">New site</a>
|
|
<?php if (isAdmin()): ?>
|
|
<a href="?action=users" class="btn">Users</a>
|
|
<?php endif; ?>
|
|
|
|
<div class="user-menu" style="position:relative;margin-left:auto;">
|
|
<button id="userMenuToggle" class="btn" type="button">
|
|
<?php echo htmlspecialchars($user['username']); ?>
|
|
<span class="muted" style="font-size:0.8rem;">(<?php echo htmlspecialchars($user['role']); ?>)</span>
|
|
▾
|
|
</button>
|
|
<div id="userMenu" style="display:none;position:absolute;right:0;margin-top:0.25rem;min-width:180px;">
|
|
<div class="card" style="padding:0.5rem;">
|
|
<a href="#" id="changePasswordLink" class="muted" style="display:block;padding:0.25rem 0;">Change password</a>
|
|
<a href="?action=logout" class="muted danger" style="display:block;padding:0.25rem 0;">Logout</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<?php
|
|
|
|
// ---------- Actions for authenticated users ----------
|
|
|
|
if ($action === 'create') {
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (!isset($_POST['op']) || $_POST['op'] !== 'self_change_password')) {
|
|
$rawDomain = $_POST['domain'] ?? '';
|
|
$domain = sanitizeDomain($rawDomain);
|
|
$projectName = trim($_POST['project_name'] ?? '');
|
|
$stackType = $_POST['stack_type'] ?? 'wp';
|
|
$isMultisite = isset($_POST['multisite']) ? $_POST['multisite'] : 'no';
|
|
$bootstrapProfile = $_POST['bootstrap_profile'] ?? 'none';
|
|
|
|
if (!$projectName) {
|
|
$projectName = $domain;
|
|
}
|
|
|
|
if (!$domain) {
|
|
echo '<div class="card"><strong>Invalid domain.</strong> Use only letters, numbers, dots and dashes.</div>';
|
|
} else {
|
|
// Build WordOps command
|
|
$flags = [];
|
|
|
|
if ($stackType === 'wp') {
|
|
$flags[] = '--wp';
|
|
} elseif ($stackType === 'wpfc') {
|
|
$flags[] = '--wpfc';
|
|
} elseif ($stackType === 'plain') {
|
|
$flags[] = '--php';
|
|
}
|
|
|
|
if ($isMultisite === 'subdir') {
|
|
$flags[] = '--wpsubdir';
|
|
} elseif ($isMultisite === 'subdomain') {
|
|
$flags[] = '--wpsubdomain';
|
|
}
|
|
|
|
$cmd = sprintf(
|
|
'sudo %s site create %s %s',
|
|
escapeshellcmd($woPath),
|
|
escapeshellarg($domain),
|
|
implode(' ', array_map('escapeshellarg', $flags))
|
|
);
|
|
|
|
echo '<div class="card">';
|
|
echo '<h2 style="margin-top:0;">Provisioning site: ' . htmlspecialchars($domain) . '</h2>';
|
|
echo '<p class="muted">WordOps log:</p>';
|
|
echo '<pre>';
|
|
|
|
@ob_flush();
|
|
@flush();
|
|
|
|
list($ok, $woLines) = runCommandStreaming($cmd, function ($line) {
|
|
echo htmlspecialchars($line) . "\n";
|
|
@ob_flush();
|
|
@flush();
|
|
});
|
|
|
|
echo '</pre>';
|
|
|
|
if ($ok) {
|
|
// Record site owner in SQLite
|
|
$db = getDb();
|
|
$stmt = $db->prepare('INSERT OR REPLACE INTO sites (domain, owner_id, created_at) VALUES (?, ?, datetime("now"))');
|
|
$stmt->execute([$domain, $user['id']]);
|
|
|
|
echo '<p><strong>Site created successfully.</strong></p>';
|
|
|
|
// Run bootstrap if requested
|
|
if ($bootstrapProfile === 'standard') {
|
|
$adminUser = $bootstrapAdminUser;
|
|
$adminEmail = $bootstrapAdminEmail;
|
|
|
|
$bootstrapArgs = [
|
|
'--domain', $domain,
|
|
'--project-name', $projectName,
|
|
'--admin-user', $adminUser,
|
|
'--admin-email', $adminEmail,
|
|
];
|
|
|
|
if ($themeStarterRepo !== '') {
|
|
$bootstrapArgs[] = '--theme-starter-repo';
|
|
$bootstrapArgs[] = $themeStarterRepo;
|
|
}
|
|
if ($themeRemoteOrigin !== '') {
|
|
$bootstrapArgs[] = '--theme-remote-origin';
|
|
$bootstrapArgs[] = $themeRemoteOrigin;
|
|
}
|
|
|
|
$bootstrapCmd = escapeshellcmd($bootstrapScript);
|
|
foreach ($bootstrapArgs as $arg) {
|
|
$bootstrapCmd .= ' ' . escapeshellarg($arg);
|
|
}
|
|
|
|
echo '<p class="muted" style="margin-top:1rem;">Bootstrap log:</p>';
|
|
echo '<pre>';
|
|
|
|
@ob_flush();
|
|
@flush();
|
|
|
|
list($bootstrapOk, $bootstrapLines) = runCommandStreaming($bootstrapCmd, function ($line) {
|
|
echo htmlspecialchars($line) . "\n";
|
|
@ob_flush();
|
|
@flush();
|
|
});
|
|
|
|
echo '</pre>';
|
|
|
|
if ($bootstrapOk) {
|
|
echo '<p><strong>Bootstrap completed:</strong> standard dev stack applied.</p>';
|
|
} else {
|
|
echo '<p><strong>Bootstrap failed:</strong> see log above.</p>';
|
|
}
|
|
}
|
|
|
|
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
|
|
} else {
|
|
echo '<p><strong>Site creation failed.</strong> See WordOps log above.</p>';
|
|
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
|
|
}
|
|
|
|
echo '</div>';
|
|
}
|
|
}
|
|
|
|
?>
|
|
|
|
<div class="card">
|
|
<h2 style="margin-top:0;">Create new site</h2>
|
|
|
|
<form method="post">
|
|
<label>
|
|
Domain
|
|
<input type="text" name="domain" placeholder="client-project.dev.internal" required>
|
|
<span class="muted">Use the same naming convention you plan for the real server.</span>
|
|
</label>
|
|
|
|
<label>
|
|
Project name
|
|
<input type="text" name="project_name" placeholder="Client Project Name">
|
|
<span class="muted">Used for site title and theme naming. Defaults to the domain if left blank.</span>
|
|
</label>
|
|
|
|
<label>
|
|
Stack type
|
|
<select name="stack_type">
|
|
<option value="wp">WordPress (no cache)</option>
|
|
<option value="wpfc">WordPress + fastcgi cache</option>
|
|
<option value="plain">Plain PHP (no WP, for tools)</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
Multisite (optional)
|
|
<select name="multisite">
|
|
<option value="no">No (single site)</option>
|
|
<option value="subdir">Multisite (subdirectory)</option>
|
|
<option value="subdomain">Multisite (subdomain)</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
Bootstrap profile
|
|
<select name="bootstrap_profile">
|
|
<option value="none">None (bare site only)</option>
|
|
<option value="standard">Standard dev stack (plugins, pages, theme)</option>
|
|
</select>
|
|
<span class="muted">“Standard” runs the shared bootstrap script after site creation.</span>
|
|
</label>
|
|
|
|
<div style="margin-top:1rem;">
|
|
<button type="submit" class="btn btn-primary">Create site</button>
|
|
<a href="?action=list" class="btn">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<?php
|
|
|
|
} elseif ($action === 'delete' && isset($_GET['domain'])) {
|
|
$domain = sanitizeDomain($_GET['domain']);
|
|
|
|
if (!$domain) {
|
|
echo '<div class="card"><strong>Invalid domain.</strong></div>';
|
|
} elseif (
|
|
$_SERVER['REQUEST_METHOD'] === 'POST' &&
|
|
isset($_POST['confirm']) &&
|
|
$_POST['confirm'] === 'yes' &&
|
|
(!isset($_POST['op']) || $_POST['op'] !== 'self_change_password')
|
|
) {
|
|
$confirmText = $_POST['confirm_text'] ?? '';
|
|
|
|
if ($confirmText !== $domain) {
|
|
echo '<div class="card"><strong>Confirmation text did not match domain.</strong></div>';
|
|
} else {
|
|
// Enforce ownership for dev users
|
|
$db = getDb();
|
|
$ownerRow = $db->prepare('SELECT owner_id FROM sites WHERE domain = ?');
|
|
$ownerRow->execute([$domain]);
|
|
$ownerId = $ownerRow->fetchColumn();
|
|
|
|
if (!isAdmin() && (int)$ownerId !== (int)$user['id']) {
|
|
echo '<div class="card"><strong>You do not have permission to delete this site.</strong></div>';
|
|
} else {
|
|
$cmd = sprintf(
|
|
'sudo %s site delete %s --no-prompt',
|
|
escapeshellcmd($woPath),
|
|
escapeshellarg($domain)
|
|
);
|
|
|
|
$output = [];
|
|
$status = null;
|
|
$ok = runCommand($cmd, $output, $status);
|
|
|
|
echo '<div class="card">';
|
|
|
|
if ($ok) {
|
|
// Remove from metadata
|
|
$stmt = $db->prepare('DELETE FROM sites WHERE domain = ?');
|
|
$stmt->execute([$domain]);
|
|
|
|
echo '<strong>Site deleted:</strong> ' . htmlspecialchars($domain) . '<br>';
|
|
} else {
|
|
echo '<strong>Failed to delete site.</strong> Check the log below.';
|
|
}
|
|
|
|
echo '<pre>' . htmlspecialchars(implode("\n", $output)) . '</pre>';
|
|
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
|
|
echo '</div>';
|
|
}
|
|
}
|
|
} else {
|
|
?>
|
|
<div class="card">
|
|
<h2 style="margin-top:0;">Delete site</h2>
|
|
<p>You're about to delete <strong><?php echo htmlspecialchars($domain); ?></strong>.</p>
|
|
<p class="muted">This will remove the vhost and files. Databases are handled by WordOps according to its defaults.</p>
|
|
|
|
<form method="post">
|
|
<label>
|
|
Type the domain to confirm
|
|
<input type="text" name="confirm_text" placeholder="<?php echo htmlspecialchars($domain); ?>" required>
|
|
</label>
|
|
|
|
<input type="hidden" name="confirm" value="yes">
|
|
|
|
<div style="margin-top:1rem;">
|
|
<button type="submit" class="btn btn-danger">Yes, delete this site</button>
|
|
<a href="?action=list" class="btn">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
} elseif ($action === 'users' && isAdmin()) {
|
|
|
|
$db = getDb();
|
|
$userMessage = null;
|
|
$userError = null;
|
|
$generatedPasswordInfo = null;
|
|
|
|
if (
|
|
$_SERVER['REQUEST_METHOD'] === 'POST' &&
|
|
isset($_POST['op']) &&
|
|
$_POST['op'] !== 'self_change_password'
|
|
) {
|
|
$op = $_POST['op'] ?? '';
|
|
|
|
if ($op === 'create') {
|
|
$username = trim($_POST['username'] ?? '');
|
|
$password = $_POST['password'] ?? '';
|
|
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
|
$role = $_POST['role'] ?? 'dev';
|
|
|
|
if ($username === '' || $password === '' || $passwordConfirm === '') {
|
|
$userError = 'Username and both password fields are required.';
|
|
} elseif ($password !== $passwordConfirm) {
|
|
$userError = 'Passwords do not match.';
|
|
} elseif (!in_array($role, ['admin', 'dev'], true)) {
|
|
$userError = 'Invalid role selected.';
|
|
} else {
|
|
// Check uniqueness
|
|
$stmt = $db->prepare('SELECT COUNT(*) FROM users WHERE username = ?');
|
|
$stmt->execute([$username]);
|
|
if ((int)$stmt->fetchColumn() > 0) {
|
|
$userError = 'Username already exists.';
|
|
} else {
|
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
|
$stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
|
|
$stmt->execute([$username, $hash, $role]);
|
|
$userMessage = 'User "' . htmlspecialchars($username) . '" created successfully.';
|
|
}
|
|
}
|
|
} elseif ($op === 'reset_password') {
|
|
$userId = (int)($_POST['user_id'] ?? 0);
|
|
if ($userId > 0) {
|
|
$stmt = $db->prepare('SELECT username FROM users WHERE id = ?');
|
|
$stmt->execute([$userId]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row) {
|
|
$userError = 'User not found.';
|
|
} else {
|
|
$newPass = generatePassword(16);
|
|
$hash = password_hash($newPass, PASSWORD_DEFAULT);
|
|
$update = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
|
|
$update->execute([$hash, $userId]);
|
|
|
|
$generatedPasswordInfo = [
|
|
'username' => $row['username'],
|
|
'password' => $newPass,
|
|
];
|
|
$userMessage = 'Password reset for user "' . htmlspecialchars($row['username']) . '".';
|
|
}
|
|
} else {
|
|
$userError = 'Invalid user ID.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch users for listing
|
|
$stmt = $db->query('SELECT id, username, role FROM users ORDER BY username ASC');
|
|
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
?>
|
|
<div class="card">
|
|
<h2 style="margin-top:0;">User Management</h2>
|
|
|
|
<?php
|
|
if ($userMessage) {
|
|
echo '<p><strong>' . $userMessage . '</strong></p>';
|
|
}
|
|
if ($userError) {
|
|
echo '<p><strong>' . htmlspecialchars($userError) . '</strong></p>';
|
|
}
|
|
if ($generatedPasswordInfo) {
|
|
echo '<p class="muted">New password for <strong>' . htmlspecialchars($generatedPasswordInfo['username']) . '</strong>: ';
|
|
echo '<code>' . htmlspecialchars($generatedPasswordInfo['password']) . '</code></p>';
|
|
}
|
|
?>
|
|
|
|
<h3>Existing users</h3>
|
|
<?php if (empty($users)): ?>
|
|
<p class="muted">No users found.</p>
|
|
<?php else: ?>
|
|
<table style="width:100%;border-collapse:collapse;margin-bottom:1rem;">
|
|
<thead>
|
|
<tr>
|
|
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Username</th>
|
|
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Role</th>
|
|
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($users as $u): ?>
|
|
<tr>
|
|
<td style="padding:0.3rem 0.2rem;"><?php echo htmlspecialchars($u['username']); ?></td>
|
|
<td style="padding:0.3rem 0.2rem;"><?php echo htmlspecialchars($u['role']); ?></td>
|
|
<td style="padding:0.3rem 0.2rem;">
|
|
<form method="post" style="display:inline;">
|
|
<input type="hidden" name="op" value="reset_password">
|
|
<input type="hidden" name="user_id" value="<?php echo (int)$u['id']; ?>">
|
|
<button type="submit" class="btn btn-danger" style="font-size:0.75rem;padding:0.2rem 0.6rem;">
|
|
Reset password
|
|
</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
|
|
<h3>Create new user</h3>
|
|
<form method="post">
|
|
<input type="hidden" name="op" value="create">
|
|
|
|
<label>
|
|
Username
|
|
<input type="text" name="username" required>
|
|
</label>
|
|
|
|
<label>
|
|
Role
|
|
<select name="role">
|
|
<option value="dev">Developer</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
Password
|
|
<input type="password" name="password" required>
|
|
</label>
|
|
|
|
<label>
|
|
Confirm password
|
|
<input type="password" name="password_confirm" required>
|
|
</label>
|
|
|
|
<div style="margin-top:1rem;">
|
|
<button type="submit" class="btn btn-primary">Create user</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<?php
|
|
|
|
} else {
|
|
// Default: site list
|
|
$cmd = sprintf('sudo %s site list', escapeshellcmd($woPath));
|
|
$output = [];
|
|
$status = null;
|
|
$ok = runCommand($cmd, $output, $status);
|
|
|
|
echo '<div class="card">';
|
|
echo '<h2 style="margin-top:0;">Sites</h2>';
|
|
|
|
if (!$ok) {
|
|
echo '<strong>Could not retrieve site list.</strong>';
|
|
echo '<pre>' . htmlspecialchars(implode("\n", $output)) . '</pre>';
|
|
} else {
|
|
$db = getDb();
|
|
|
|
// Map of domain => [owner_id, username, role]
|
|
$meta = [];
|
|
$stmt = $db->query('
|
|
SELECT s.domain, s.owner_id, u.username, u.role
|
|
FROM sites s
|
|
LEFT JOIN users u ON s.owner_id = u.id
|
|
');
|
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
|
$meta[$row['domain']] = $row;
|
|
}
|
|
|
|
$sites = [];
|
|
foreach ($output as $line) {
|
|
$line = trim($line);
|
|
|
|
// Skip header / separator lines and the panel site itself
|
|
if (
|
|
$line === '' ||
|
|
strpos($line, 'Site') !== false ||
|
|
strpos($line, 'site') !== false ||
|
|
strpos($line, '---') !== false ||
|
|
$line === 'dev-panel.local'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Take the first column as the domain
|
|
$parts = preg_split('/\s+/', $line);
|
|
if (!$parts || !isset($parts[0])) {
|
|
continue;
|
|
}
|
|
|
|
$domain = sanitizeDomain($parts[0]);
|
|
if (!$domain) {
|
|
continue;
|
|
}
|
|
|
|
$ownerInfo = $meta[$domain] ?? null;
|
|
|
|
// Role-based filtering: dev sees only their own; admin sees all
|
|
if (!isAdmin()) {
|
|
if (!$ownerInfo || (int)$ownerInfo['owner_id'] !== (int)$user['id']) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$sites[] = [
|
|
'domain' => $domain,
|
|
'owner' => $ownerInfo['username'] ?? null,
|
|
'ownerRole' => $ownerInfo['role'] ?? null,
|
|
'ownerId' => $ownerInfo['owner_id'] ?? null,
|
|
];
|
|
}
|
|
|
|
if (empty($sites) || (count($sites) === 1 && strPos($sites[0]['domain'], 'dev-panel') !== false)) {
|
|
echo '<p class="muted">No sites found for your account yet. <a href="?action=create">Create your first site</a>.</p>';
|
|
} else {
|
|
echo '<ul style="list-style:none;padding-left:0;">';
|
|
foreach ($sites as $siteInfo) {
|
|
if (strPos($siteInfo['domain'], 'dev-panel') !== false) {
|
|
continue;
|
|
}
|
|
|
|
$site = $siteInfo['domain'];
|
|
$owner = $siteInfo['owner'];
|
|
$siteEsc = htmlspecialchars($site);
|
|
$url = 'http://' . $site;
|
|
$adminUrl = 'http://' . $site . '/wp-admin/';
|
|
|
|
echo '<li style="margin-bottom:0.75rem;">';
|
|
echo '<strong>' . $siteEsc . '</strong>';
|
|
echo ' <span class="tag">dev</span>';
|
|
if ($owner) {
|
|
echo ' <span class="muted" style="margin-left:0.5rem;">Owner: ' . htmlspecialchars($owner) . '</span>';
|
|
} else {
|
|
echo ' <span class="muted" style="margin-left:0.5rem;">Owner: Unassigned</span>';
|
|
}
|
|
echo '<br>';
|
|
echo '<a href="' . $url . '" target="_blank" rel="noopener">Open site</a> · ';
|
|
echo '<a href="' . $adminUrl . '" target="_blank" rel="noopener">WP Admin</a> · ';
|
|
echo '<a href="?action=delete&domain=' . urlencode($site) . '" class="muted danger">Delete…</a>';
|
|
echo '</li>';
|
|
}
|
|
echo '</ul>';
|
|
}
|
|
}
|
|
|
|
echo '</div>';
|
|
}
|
|
|
|
?>
|
|
|
|
<!-- Change Password Modal -->
|
|
<div id="passwordModal" style="display:none;position:fixed;inset:0;z-index:50;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);">
|
|
<div class="card" style="max-width:420px;width:90%;margin:0 auto;">
|
|
<h2 style="margin-top:0;">Change password</h2>
|
|
|
|
<?php if ($selfPasswordError): ?>
|
|
<p><strong><?php echo htmlspecialchars($selfPasswordError); ?></strong></p>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($selfPasswordMessage): ?>
|
|
<p><strong><?php echo htmlspecialchars($selfPasswordMessage); ?></strong></p>
|
|
<?php endif; ?>
|
|
|
|
<form method="post">
|
|
<input type="hidden" name="op" value="self_change_password">
|
|
|
|
<label>
|
|
Current password
|
|
<input type="password" name="current_password" required>
|
|
</label>
|
|
|
|
<label>
|
|
New password
|
|
<input type="password" name="new_password" required>
|
|
</label>
|
|
|
|
<label>
|
|
Confirm new password
|
|
<input type="password" name="new_password_confirm" required>
|
|
</label>
|
|
|
|
<div style="margin-top:1rem;display:flex;gap:0.5rem;justify-content:flex-end;">
|
|
<button type="button" class="btn" id="passwordModalClose">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Update password</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const userMenuToggle = document.getElementById('userMenuToggle');
|
|
const userMenu = document.getElementById('userMenu');
|
|
const changePasswordLink = document.getElementById('changePasswordLink');
|
|
const passwordModal = document.getElementById('passwordModal');
|
|
const passwordModalClose = document.getElementById('passwordModalClose');
|
|
|
|
if (userMenuToggle && userMenu) {
|
|
userMenuToggle.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
userMenu.style.display = (userMenu.style.display === 'block') ? 'none' : 'block';
|
|
});
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (!userMenu.contains(e.target) && e.target !== userMenuToggle) {
|
|
userMenu.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
function openPasswordModal() {
|
|
if (passwordModal) {
|
|
passwordModal.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
function closePasswordModal() {
|
|
if (passwordModal) {
|
|
passwordModal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
if (changePasswordLink) {
|
|
changePasswordLink.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
openPasswordModal();
|
|
});
|
|
}
|
|
|
|
if (passwordModalClose) {
|
|
passwordModalClose.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
closePasswordModal();
|
|
});
|
|
}
|
|
|
|
if (passwordModal) {
|
|
passwordModal.addEventListener('click', function (e) {
|
|
if (e.target === passwordModal) {
|
|
closePasswordModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
<?php if ($passwordDialogOpen): ?>
|
|
// Re-open dialog after POST for feedback
|
|
openPasswordModal();
|
|
<?php endif; ?>
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|