✨feature: Initial commit
This commit is contained in:
259
dev-panel.vincentdevelopment.ca/htdocs/README.md
Normal file
259
dev-panel.vincentdevelopment.ca/htdocs/README.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# WordOps Dev Panel
|
||||
|
||||
A lightweight internal control panel for managing WordOps WordPress sites without forcing developers onto the CLI.
|
||||
|
||||
## What this does
|
||||
|
||||
- Lists WordOps sites (hides `dev-panel.local`)
|
||||
- Create / delete sites via WordOps
|
||||
- Optional bootstrap step after site creation (`wp-dev-bootstrap.sh`)
|
||||
- User auth + roles:
|
||||
- `admin` can see/manage all sites + manage users
|
||||
- `dev` can only see/manage sites they created
|
||||
- SQLite-backed storage (no external DB required)
|
||||
- Password change modal + logout under a username dropdown
|
||||
- Streaming output for long-running bootstrap (WordOps output may still arrive in chunks)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Server
|
||||
|
||||
- Up-to-date Linux OS (built and tested on Ubuntu 24.04)
|
||||
- [WordOps](https://wordops.net/) installed and working (`/usr/local/bin/wo`)
|
||||
- [WP-CLI](https://wp-cli.org/) installed and working (`wp` in PATH)
|
||||
- Nginx + PHP-FPM (WordOps provides this)
|
||||
|
||||
### PHP packages
|
||||
|
||||
Install SQLite support for PHP (required):
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y php-sqlite3
|
||||
sudo systemctl restart php8.3-fpm || true
|
||||
sudo systemctl restart php-fpm || true
|
||||
```
|
||||
> Adjust `php8.3-fpm` to your PHP version if needed.
|
||||
|
||||
### File layout
|
||||
|
||||
Panel site lives at:
|
||||
- `/var/www/<dev panel URL>/htdocs/index.php`
|
||||
- `/var/www/<dev panel URL>/htdocs/includes/db.php`
|
||||
- `/var/www/<dev panel URL>/htdocs/includes/functions.php`
|
||||
- `/var/www/<dev panel URL>/htdocs/style.css`
|
||||
- `/var/www/<dev panel URL>/htdocs/panel.sqlite (auto-created)`
|
||||
|
||||
Scripts live at:
|
||||
- `/usr/local/bin/wp-dev-bootstrap.sh`
|
||||
- `/usr/local/bin/wo-fix-perms.sh` (optional)
|
||||
|
||||
|
||||
## Install steps
|
||||
|
||||
1. Create the WordOps “panel” site
|
||||
|
||||
`sudo wo site create <dev panel URL> --php`
|
||||
|
||||
Add host entry on your workstation (or internal DNS), then confirm you can load the site.
|
||||
|
||||
2. Drop in the panel files
|
||||
|
||||
Copy:
|
||||
- `index.php` → `/var/www/<dev panel URL>/htdocs/index.php`
|
||||
- `style.css` → `/var/www/<dev panel URL>/htdocs/style.css`
|
||||
Make sure the web server can write the SQLite DB (the panel will create it on first load):
|
||||
|
||||
`sudo chown -R www-data:www-data /var/www/<dev panel URL>/htdocs`
|
||||
> Optional: lock down later once seeded; see permissions section below
|
||||
|
||||
3. Install bootstrap + helper scripts
|
||||
|
||||
Copy:
|
||||
- `wp-dev-bootstrap.sh` → `/usr/local/bin/wp-dev-bootstrap.sh` (custom bootstrap script)
|
||||
- `wo-fix-perms.sh` → `/usr/local/bin/wo-fix-perms.sh` (optional)
|
||||
|
||||
Then:
|
||||
- `sudo chmod +x /usr/local/bin/wp-dev-bootstrap.sh`
|
||||
- `sudo chmod +x /usr/local/bin/wo-fix-perms.sh`
|
||||
|
||||
4. Allow www-data to run WordOps + scripts via sudo
|
||||
|
||||
Create sudoers file: `sudo visudo -f /etc/sudoers.d/dev-panel`
|
||||
|
||||
Contents:
|
||||
```text
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/wo *
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/wp-dev-bootstrap.sh *
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/wo-fix-perms.sh *
|
||||
```
|
||||
|
||||
This lets the panel (running as www-data) execute the exact commands it needs as root.
|
||||
|
||||
Do not add `www-data` to the sudo group.
|
||||
|
||||
Test:
|
||||
`sudo -u www-data sudo /usr/local/bin/wo site list`
|
||||
|
||||
|
||||
## First login / seeding
|
||||
|
||||
On first load, if there are no users, the panel auto-creates:
|
||||
|
||||
```text
|
||||
Username: admin
|
||||
Password: change-me
|
||||
```
|
||||
|
||||
Log in and change it immediately using the user dropdown → “Change password”.
|
||||
|
||||
### Ownership & permissions strategy
|
||||
|
||||
You have two competing needs:
|
||||
|
||||
1. WordPress / PHP needs to write certain files
|
||||
2. Developers need to edit themes/plugins without being root
|
||||
|
||||
A safe, simple model is group-based permissions.
|
||||
|
||||
```bash
|
||||
# Create a shared dev group
|
||||
sudo groupadd webdev || true
|
||||
|
||||
# Add all devs to group
|
||||
sudo usermod -aG webdev <devUser1>
|
||||
sudo usermod -aG webdev <devUser2>
|
||||
...
|
||||
|
||||
# Add web server user
|
||||
sudo usermod -aG webdev www-data
|
||||
```
|
||||
|
||||
Log out and back in for group membership to apply.
|
||||
|
||||
### Set group ownership + setgid under `/var/www`
|
||||
|
||||
```bash
|
||||
sudo chown -R root:webdev /var/www
|
||||
|
||||
# Directories: 2775 (setgid + group writable)
|
||||
sudo find /var/www -type d -exec chmod 2775 {} \;
|
||||
|
||||
# Files: 664 (group writable)
|
||||
sudo find /var/www -type f -exec chmod 664 {} \;
|
||||
```
|
||||
|
||||
### Ensure new files stay group-writable
|
||||
|
||||
Make sure your bootstrap script starts with `umask 0002`
|
||||
|
||||
If WordOps creates files with different perms, run the optional fixer after site creation:
|
||||
|
||||
`sudo /usr/local/bin/wo-fix-perms.sh example.local`
|
||||
|
||||
### SSH keys for private repos (bootstrap theme cloning)
|
||||
|
||||
If your bootstrap clones private repos, you’ll need a key that can access them.
|
||||
|
||||
**Option A** (recommended): deploy key or bot account key for the server
|
||||
|
||||
Create `/var/www/.ssh/` or `/home/<serviceUser>/.ssh/` depending on your model
|
||||
|
||||
**Ensure correct perms:**
|
||||
|
||||
- `/var/www/.ssh` or `/home/<serviceUser>/.ssh` = 700
|
||||
- private key = 600
|
||||
|
||||
Add to GitHub as a deploy key or bot account key
|
||||
|
||||
Ensure `known_hosts` contains github.com to avoid prompts:
|
||||
|
||||
`sudo -u www-data ssh-keyscan github.com >> /var/www/.ssh/known_hosts`
|
||||
|
||||
**Option B:** keep bootstrap theme cloning optional and run theme cloning from a dev account via VSCode Remote.
|
||||
|
||||
**Don’t store a personal private key in a shared server environment.**
|
||||
|
||||
|
||||
## Daily workflow
|
||||
|
||||
**Option A:** (recommended) [VSCode Remote - SSH](https://code.visualstudio.com/docs/remote/ssh)
|
||||
|
||||
- Devs connect via SSH to the server
|
||||
- Edit project files directly under `/var/www/<site>/htdocs`
|
||||
- Panel handles provisioning + bootstrap + ownership metadata
|
||||
- No need for tooling (`node`, `php`, etc) on user machines beyond VSCode + SSH
|
||||
|
||||
**Option B:** SMB shares (with SSH tunnel)
|
||||
|
||||
- Export `/var/www` (or per-site roots) via Samba
|
||||
- Use group permissions (webdev) so edits behave identically to SSH
|
||||
- Map network drives on dev machines
|
||||
- Requires tooling (`node`, `php`, etc) on user machines for composer, Tailwind, etc
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### “PDOException: could not find driver”
|
||||
|
||||
PHP SQLite extension missing:
|
||||
|
||||
```bash
|
||||
sudo apt install -y php-sqlite3
|
||||
sudo systemctl restart php8.3-fpm || sudo systemctl restart php-fpm
|
||||
```
|
||||
|
||||
### WordOps fails when run as www-data
|
||||
|
||||
Don’t run wo as www-data directly:
|
||||
|
||||
**Correct** (what the panel does):
|
||||
|
||||
`sudo -u www-data sudo /usr/local/bin/wo site list`
|
||||
|
||||
**Incorrect**:
|
||||
|
||||
`sudo -u www-data /usr/local/bin/wo site list`
|
||||
|
||||
### WordOps delete prompts / EOFError
|
||||
|
||||
Use `--no-prompt` on deletes (panel already does).
|
||||
|
||||
### Panel isn’t streaming output
|
||||
|
||||
Bootstrap output streams (proc_open + flush)
|
||||
|
||||
WordOps sometimes buffers; that’s normal. The panel will still show output when it arrives.
|
||||
|
||||
|
||||
## Security notes
|
||||
|
||||
The panel’s `sudoers` file is the main security boundary:
|
||||
|
||||
- Keep it as narrow as possible
|
||||
- Avoid wildcarding unrelated commands
|
||||
- Consider restricting panel access by:
|
||||
- Internal network only
|
||||
- VPN only
|
||||
- HTTP basic auth in front of it
|
||||
- Keep OS patches current
|
||||
- Regularly audit panel users + roles
|
||||
|
||||
|
||||
## Backups (minimum viable)
|
||||
|
||||
At minimum, back up:
|
||||
|
||||
- /var/www (all site roots)
|
||||
- Databases (WordOps MariaDB/MySQL)
|
||||
- `/etc/nginx` and WordOps configs (optional but helpful)
|
||||
- Panel SQLite DB:
|
||||
- `/var/www/dev-panel.local/htdocs/panel.sqlite`
|
||||
|
||||
|
||||
## Next steps / nice-to-haves
|
||||
|
||||
- “Fix perms” button in the panel post-create
|
||||
- Per-site notes (who/why) for management visibility
|
||||
- Audit log for create/delete/bootstrap actions
|
||||
- Optional “clone template site” support
|
||||
48
dev-panel.vincentdevelopment.ca/htdocs/includes/db.php
Normal file
48
dev-panel.vincentdevelopment.ca/htdocs/includes/db.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
$dbPath = __DIR__ . '/../dev_panel.db';
|
||||
|
||||
function getDb() {
|
||||
static $pdo = null;
|
||||
global $dbPath;
|
||||
|
||||
if ($pdo === null) {
|
||||
$pdo = new PDO('sqlite:' . $dbPath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
$db = getDb();
|
||||
$db->exec('
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ("admin","dev"))
|
||||
);
|
||||
');
|
||||
|
||||
$db->exec('
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
owner_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
');
|
||||
|
||||
// Seed initial admin if no users exist
|
||||
$count = (int) $db->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
if ($count === 0) {
|
||||
$hash = password_hash('change-me', PASSWORD_DEFAULT);
|
||||
$stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
|
||||
$stmt->execute(['admin', $hash, 'admin']);
|
||||
return ['adminCreated' => true, 'username' => 'admin', 'password' => 'change-me'];
|
||||
}
|
||||
|
||||
return ['adminCreated' => false];
|
||||
}
|
||||
|
||||
128
dev-panel.vincentdevelopment.ca/htdocs/includes/functions.php
Normal file
128
dev-panel.vincentdevelopment.ca/htdocs/includes/functions.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
function getCurrentUser() {
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $_SESSION['user_id'],
|
||||
'username' => $_SESSION['username'],
|
||||
'role' => $_SESSION['role'],
|
||||
];
|
||||
}
|
||||
|
||||
function isAdmin() {
|
||||
$user = getCurrentUser();
|
||||
return $user && $user['role'] === 'admin';
|
||||
}
|
||||
|
||||
function requireLogin($action) {
|
||||
$publicActions = ['login'];
|
||||
if (!in_array($action, $publicActions, true) && !isset($_SESSION['user_id'])) {
|
||||
header('Location: ?action=login');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Misc helpers ----------
|
||||
|
||||
function generatePassword($length = 16) {
|
||||
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
||||
$pass = '';
|
||||
$max = strlen($chars) - 1;
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$pass .= $chars[random_int(0, $max)];
|
||||
}
|
||||
return $pass;
|
||||
}
|
||||
|
||||
// ---------- CLI Helpers ----------
|
||||
|
||||
function stripAnsi($str) {
|
||||
// Remove ANSI escape sequences like \e[94m ... \e[0m
|
||||
return preg_replace('/\x1B\[[0-9;]*[A-Za-z]/', '', $str);
|
||||
}
|
||||
|
||||
function runCommand($cmd, &$output = null, &$status = null) {
|
||||
$output = [];
|
||||
exec($cmd . ' 2>&1', $output, $status);
|
||||
|
||||
// Strip ANSI codes from each line
|
||||
foreach ($output as &$line) {
|
||||
$line = stripAnsi($line);
|
||||
}
|
||||
|
||||
return $status === 0;
|
||||
}
|
||||
|
||||
function runCommandStreaming($cmd, callable $onLine) {
|
||||
$descriptorspec = [
|
||||
0 => ['pipe', 'r'], // stdin
|
||||
1 => ['pipe', 'w'], // stdout
|
||||
2 => ['pipe', 'w'], // stderr
|
||||
];
|
||||
|
||||
$process = proc_open($cmd . ' 2>&1', $descriptorspec, $pipes);
|
||||
|
||||
if (!is_resource($process)) {
|
||||
return [false, ['Failed to start process']];
|
||||
}
|
||||
|
||||
// We don't need stdin
|
||||
fclose($pipes[0]);
|
||||
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
$allLines = [];
|
||||
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
|
||||
$out = fgets($pipes[1]);
|
||||
$err = fgets($pipes[2]);
|
||||
|
||||
foreach ([$out, $err] as $chunk) {
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
$chunk = stripAnsi($chunk);
|
||||
$lines = preg_split("/\r\n|\n|\r/", $chunk);
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$allLines[] = $line;
|
||||
$onLine($line);
|
||||
}
|
||||
|
||||
@ob_flush();
|
||||
@flush();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50000); // 50ms
|
||||
}
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$exitCode = proc_close($process);
|
||||
return [$exitCode === 0, $allLines];
|
||||
}
|
||||
|
||||
function sanitizeDomain($domain) {
|
||||
$domain = trim($domain);
|
||||
if ($domain === '') {
|
||||
return null;
|
||||
}
|
||||
// Allow letters, numbers, dots, and dashes
|
||||
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $domain)) {
|
||||
return null;
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
837
dev-panel.vincentdevelopment.ca/htdocs/index.php
Normal file
837
dev-panel.vincentdevelopment.ca/htdocs/index.php
Normal file
@@ -0,0 +1,837 @@
|
||||
<?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>
|
||||
190
dev-panel.vincentdevelopment.ca/htdocs/style.css
Normal file
190
dev-panel.vincentdevelopment.ca/htdocs/style.css
Normal file
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
--bg: #020617;
|
||||
--card-bg: #020617;
|
||||
--border: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #38bdf8;
|
||||
--accent-soft: rgba(56, 189, 248, 0.1);
|
||||
--danger: #f97373;
|
||||
--danger-soft: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||
background: radial-gradient(circle at top, #020617 0, #020617 40%, #020617 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
header {
|
||||
max-width: 960px;
|
||||
margin: 1.5rem auto 1rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(15, 23, 42, 1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
/* Danger links */
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #020617;
|
||||
color: var(--text);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.7);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Pre */
|
||||
|
||||
pre {
|
||||
background: #020617;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Small screens */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user