Add import/export and update system
Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,7 +46,7 @@ Desktop.ini
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
/database/backups/
|
||||
/backups/
|
||||
|
||||
# Development
|
||||
/tests/coverage/
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -5,6 +5,56 @@ All notable changes to Domain Monitor will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.3] - 2026-02-11
|
||||
|
||||
### Added
|
||||
- **CSV/JSON Import & Export for Domains** - Export all domains with tags, groups, and notes; import from file with WHOIS auto-lookup, group matching by name, and duplicate skip
|
||||
- **CSV/JSON Import & Export for Tags** - Export/import user tags with human-readable color names and descriptions
|
||||
- **CSV/JSON Import & Export for Notification Groups** - Export groups with channels (sensitive data masked); import with auto-disable for masked credentials
|
||||
- **In-App Update System** - Check, download, and apply updates directly from Settings (GitHub Releases & hotfix tracking)
|
||||
- Two update channels: Stable (releases only) and Latest (releases + hotfixes)
|
||||
- Full file and database backup before every update, with one-click rollback
|
||||
- Automatic `composer install` when dependencies change (detects cPanel/shared hosting limitations)
|
||||
- Commit SHA integrity verification on downloaded archives
|
||||
- Update badge in top navigation bar (admin-only, configurable)
|
||||
- Cron-based background update checks with admin notifications
|
||||
- **Update Available Notifications** - In-app alerts for admins when a new release or hotfix is detected
|
||||
- **Tag Transfer** - Admin-only transfer of individual or bulk-selected tags to another user
|
||||
- **Domain Bulk Transfer** - Admin-only bulk transfer of selected domains to another user
|
||||
- **Drag-and-Drop File Upload** - File import zones on Domains (bulk-add), Tags, and Groups pages with format hints and size limits
|
||||
|
||||
### Changed
|
||||
- **Bulk Action Bars Redesigned** - Consistent inline toolbar across Domains, Tags, Groups, Users, Errors, and TLD Registry
|
||||
- **Notification Click Routing** - `update_available` notifications redirect to Settings → Updates tab
|
||||
- **Domains Per-Page Preference** - Remembered via cookie (persists for 1 year)
|
||||
- **Installer Route Protection** - Requires admin auth for post-install routes; blocks re-installation
|
||||
- **Settings Page** - New Updates tab with status card, preferences, rollback, and release notes viewer (Markdown rendered via marked.js + DOMPurify)
|
||||
- **Button Color Consistency** - TLD Registry and transfer modals use `bg-primary` branding instead of mixed indigo/green
|
||||
- **ErrorHandler Hardened** - Recursion guard, `JSON_PARTIAL_OUTPUT_ON_ERROR` for stack traces, `\Throwable` catch, graceful fallback to `error_log()`
|
||||
|
||||
### Fixed
|
||||
- **Tag Delete XSS** - Fixed escaping of tag names containing quotes in delete confirmation
|
||||
- **Bulk Actions Bar Toggle Bug** - Removed flex class toggling that caused display issues
|
||||
|
||||
### Security
|
||||
- **Sensitive Data Masking in Exports** - API tokens show `****` + last 4 chars; webhook URLs show scheme + host only; masked channels imported as disabled
|
||||
- **Installer Access Control** - Post-install pages (update, migration runner) require admin authentication
|
||||
- **Import Validation** - File size limits (5 MB domains, 2 MB groups, 1 MB tags), extension whitelist (`.csv`, `.json`), CSRF on all import forms
|
||||
|
||||
### Technical
|
||||
- **UpdateController** - New admin-only controller with check, apply, rollback, and preference endpoints
|
||||
- **UpdateService** - GitHub API integration with release/commit tracking, file + DB backup, staged extraction, and rollback
|
||||
- **LayoutHelper::getUpdateBadgeInfo()** - Cached badge state for top-nav without API calls on page load
|
||||
- **ViewHelper::getMaxUploadSize()** - Returns effective PHP upload limit as human-readable string
|
||||
- **NotificationGroup::findByName()** - Lookup groups by name with optional user scope
|
||||
- **Setting::getUpdateSettings()** - Returns all update-related settings in one call
|
||||
- **In-memory CSV building** - Uses `php://temp` streams to avoid output buffer conflicts
|
||||
|
||||
### Migrations
|
||||
- `025_add_update_system_v1.1.3.sql` - Adds `update_channel` and `update_badge_enabled` settings, updates app version to 1.1.3
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2026-02-09
|
||||
|
||||
### Added
|
||||
@@ -393,8 +443,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- [ ] SMS notifications (Twilio)
|
||||
- [x] Google Chat notifications (completed - v1.1.2)
|
||||
- [ ] WhatsApp notifications
|
||||
- [ ] Export functionality (CSV, PDF)
|
||||
- [ ] Import domains from CSV
|
||||
- [x] Export functionality (CSV, JSON) (completed - v1.1.3)
|
||||
- [x] Import domains from CSV/JSON (completed - v1.1.3)
|
||||
- [ ] Domain transfer tracking
|
||||
- [ ] DNS record monitoring
|
||||
- [ ] SSL certificate monitoring
|
||||
@@ -416,6 +466,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Version History
|
||||
|
||||
### 1.1.3 (2026-02-11)
|
||||
- **CSV/JSON Import & Export** - Domains, Tags, and Notification Groups with drag-and-drop file upload
|
||||
- **Sensitive Data Masking** - API tokens and webhook URLs masked in group exports; masked channels imported as disabled
|
||||
- **In-App Update System** - Check, apply, and rollback updates from Settings (GitHub Releases + hotfix tracking)
|
||||
- **Update Channels** - Stable (releases only) or Latest (releases + hotfixes) with configurable badge
|
||||
- **File & Database Backup** - Automatic backup before every update, one-click rollback
|
||||
- **Update Notifications** - In-app alerts for admins when new releases or hotfixes are detected
|
||||
- **Tag Transfer** - Admin-only individual and bulk transfer of tags between users
|
||||
- **Domain Bulk Transfer** - Admin-only bulk transfer of domains to another user
|
||||
- **Bulk Action Bars Redesigned** - Consistent inline toolbar styling across all list pages
|
||||
- **Installer Hardened** - Admin auth required post-install; re-installation blocked
|
||||
- **ErrorHandler Improvements** - Recursion guard, graceful fallback logging, `\Throwable` catch
|
||||
- Migration: `025_add_update_system_v1.1.3.sql`
|
||||
|
||||
### 1.1.2 (2026-02-09)
|
||||
- **Google Chat Webhook Support** - Selectable payload formats (Generic, Google Chat, Simple Text)
|
||||
- **Domain Status Change Notifications** - Configurable alerts for available, registered, expired, redemption_period, pending_delete
|
||||
|
||||
@@ -51,7 +51,13 @@ class DomainController extends Controller
|
||||
$sortBy = $_GET['sort'] ?? 'domain_name';
|
||||
$sortOrder = $_GET['order'] ?? 'asc';
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100
|
||||
// Remember per_page preference via cookie
|
||||
if (isset($_GET['per_page'])) {
|
||||
$perPage = max(10, min(100, (int)$_GET['per_page']));
|
||||
setcookie('domains_per_page', (string)$perPage, time() + 365 * 24 * 60 * 60, '/');
|
||||
} else {
|
||||
$perPage = max(10, min(100, (int)($_COOKIE['domains_per_page'] ?? 25)));
|
||||
}
|
||||
|
||||
// Get expiring threshold from settings
|
||||
$notificationDays = $settingModel->getNotificationDays();
|
||||
@@ -114,6 +120,260 @@ class DomainController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export domains as CSV or JSON
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
$logger = new \App\Services\Logger('export');
|
||||
|
||||
try {
|
||||
$userId = \Core\Auth::id();
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
$format = $_GET['format'] ?? 'csv';
|
||||
$logger->info("Domains export started", ['format' => $format, 'user_id' => $userId]);
|
||||
|
||||
if (!in_array($format, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid export format';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all domains with groups and tags
|
||||
$domains = $this->domainModel->getAllWithGroups($isolationMode === 'isolated' ? $userId : null);
|
||||
|
||||
$exportData = [];
|
||||
foreach ($domains as $domain) {
|
||||
$exportData[] = [
|
||||
'domain_name' => $domain['domain_name'],
|
||||
'status' => $domain['status'] ?? '',
|
||||
'registrar' => $domain['registrar'] ?? '',
|
||||
'expiration_date' => $domain['expiration_date'] ?? '',
|
||||
'tags' => $domain['tags'] ?? '',
|
||||
'notification_group' => $domain['group_name'] ?? '',
|
||||
'notes' => $domain['notes'] ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
$date = date('Y-m-d');
|
||||
$filename = "domains_export_{$date}";
|
||||
|
||||
// Clean any prior output buffers to prevent header conflicts
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
if ($format === 'json') {
|
||||
header('Content-Type: application/json');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// Build CSV in memory to avoid fopen('php://output') issues
|
||||
$csvContent = $this->buildCsv($exportData, ['domain_name', 'status', 'registrar', 'expiration_date', 'tags', 'notification_group', 'notes']);
|
||||
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
|
||||
header('Content-Length: ' . strlen($csvContent));
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo $csvContent;
|
||||
}
|
||||
|
||||
$logger->info("Domains export completed successfully");
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error("Domains export failed", [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
]);
|
||||
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
|
||||
$this->redirect('/domains');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSV string in memory from array data
|
||||
*/
|
||||
private function buildCsv(array $rows, array $headers): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, $headers, ',', '"', '\\');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, array_values($row), ',', '"', '\\');
|
||||
}
|
||||
rewind($handle);
|
||||
$csv = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import domains from CSV or JSON file
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains/bulk-add');
|
||||
|
||||
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$_SESSION['error'] = 'Please select a valid file to import';
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $_FILES['import_file'];
|
||||
|
||||
// Validate file size (5MB max for domains)
|
||||
if ($file['size'] > 5242880) {
|
||||
$_SESSION['error'] = 'File is too large. Maximum size is 5MB';
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file['tmp_name']);
|
||||
$domainsData = [];
|
||||
|
||||
if ($ext === 'json') {
|
||||
$parsed = json_decode($content, true);
|
||||
if (!is_array($parsed)) {
|
||||
$_SESSION['error'] = 'Invalid JSON file';
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
$domainsData = $parsed;
|
||||
} else {
|
||||
$lines = array_filter(explode("\n", $content));
|
||||
$header = null;
|
||||
foreach ($lines as $line) {
|
||||
$row = str_getcsv(trim($line), ',', '"', '\\');
|
||||
if (!$header) {
|
||||
$header = array_map('strtolower', array_map('trim', $row));
|
||||
continue;
|
||||
}
|
||||
$item = [];
|
||||
foreach ($header as $i => $col) {
|
||||
$item[$col] = $row[$i] ?? '';
|
||||
}
|
||||
$domainsData[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($domainsData)) {
|
||||
$_SESSION['error'] = 'No domains found in file';
|
||||
$this->redirect('/domains/bulk-add');
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = \Core\Auth::id();
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
$tagModel = new \App\Models\Tag();
|
||||
|
||||
// Form-level notification group
|
||||
$formGroupId = (int)($_POST['notification_group_id'] ?? 0);
|
||||
|
||||
$added = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
$logger = new \App\Services\Logger();
|
||||
|
||||
foreach ($domainsData as $row) {
|
||||
$domainName = strtolower(trim($row['domain_name'] ?? ''));
|
||||
if (empty($domainName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove protocol/www
|
||||
$domainName = preg_replace('#^https?://#', '', $domainName);
|
||||
$domainName = preg_replace('#^www\.#', '', $domainName);
|
||||
$domainName = rtrim($domainName, '/');
|
||||
|
||||
if ($this->domainModel->existsByDomain($domainName)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch WHOIS data
|
||||
$whoisData = $this->whoisService->getDomainInfo($domainName);
|
||||
|
||||
if (!$whoisData) {
|
||||
$errors[] = $domainName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $this->whoisService->getDomainStatus(
|
||||
$whoisData['expiration_date'] ?? null,
|
||||
$whoisData['status'] ?? [],
|
||||
$whoisData
|
||||
);
|
||||
|
||||
// Determine notification group: from file column or form fallback
|
||||
$groupId = null;
|
||||
$groupName = trim($row['notification_group'] ?? '');
|
||||
if (!empty($groupName)) {
|
||||
$groupStmt = $this->groupModel->findByName($groupName, $isolationMode === 'isolated' ? $userId : null);
|
||||
if ($groupStmt) {
|
||||
$groupId = $groupStmt['id'];
|
||||
}
|
||||
}
|
||||
if (!$groupId && $formGroupId > 0) {
|
||||
$groupId = $formGroupId;
|
||||
}
|
||||
|
||||
$domainId = $this->domainModel->create([
|
||||
'domain_name' => $domainName,
|
||||
'registrar' => $whoisData['registrar'] ?? null,
|
||||
'registrar_url' => $whoisData['registrar_url'] ?? null,
|
||||
'expiration_date' => $whoisData['expiration_date'] ?? null,
|
||||
'updated_date' => $whoisData['updated_date'] ?? null,
|
||||
'abuse_email' => $whoisData['abuse_email'] ?? null,
|
||||
'status' => $status,
|
||||
'whois_data' => json_encode($whoisData),
|
||||
'notes' => trim($row['notes'] ?? ''),
|
||||
'last_checked' => date('Y-m-d H:i:s'),
|
||||
'notification_group_id' => $groupId,
|
||||
'user_id' => $isolationMode === 'isolated' ? $userId : null
|
||||
]);
|
||||
|
||||
// Handle tags from file
|
||||
$fileTags = trim($row['tags'] ?? '');
|
||||
if (!empty($fileTags) && $domainId) {
|
||||
$tagModel->updateDomainTags($domainId, $fileTags, $userId);
|
||||
}
|
||||
|
||||
if ($domainId) {
|
||||
$added++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = $domainName;
|
||||
$logger->error('Domain import failed', ['domain' => $domainName, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$msg = "{$added} domain(s) imported successfully";
|
||||
if ($skipped > 0) $msg .= ", {$skipped} skipped (already exist)";
|
||||
if (!empty($errors)) $msg .= ", " . count($errors) . " failed";
|
||||
$_SESSION['success'] = $msg;
|
||||
$this->redirect('/domains');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Get groups based on isolation mode
|
||||
|
||||
@@ -55,6 +55,7 @@ class InstallerController extends Controller
|
||||
'022_add_pushover_channel_type.sql',
|
||||
'023_update_app_version_to_1.1.1.sql',
|
||||
'024_add_status_notifications_v1.1.2.sql',
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -196,6 +197,7 @@ class InstallerController extends Controller
|
||||
'022_add_pushover_channel_type.sql',
|
||||
'023_update_app_version_to_1.1.1.sql',
|
||||
'024_add_status_notifications_v1.1.2.sql',
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -222,12 +224,33 @@ class InstallerController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin authentication (for post-install routes)
|
||||
* Redirects to login if not authenticated, or home if not admin.
|
||||
*/
|
||||
private function requireAdmin(): void
|
||||
{
|
||||
if (!\Core\Auth::check()) {
|
||||
$_SESSION['error'] = 'Please log in as an administrator to access this page.';
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show installer welcome page
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->isInstalled()) {
|
||||
// System is installed — require admin for any further access
|
||||
$this->requireAdmin();
|
||||
|
||||
// Check for pending migrations without executing them
|
||||
$pending = $this->getPendingMigrations(false);
|
||||
if (empty($pending)) {
|
||||
@@ -250,6 +273,13 @@ class InstallerController extends Controller
|
||||
*/
|
||||
public function checkDatabase()
|
||||
{
|
||||
// Block access if already installed
|
||||
if ($this->isInstalled()) {
|
||||
$_SESSION['info'] = 'System is already installed.';
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
$pdo->query("SELECT 1");
|
||||
@@ -277,6 +307,13 @@ class InstallerController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Block re-installation if already installed
|
||||
if ($this->isInstalled()) {
|
||||
$_SESSION['error'] = 'System is already installed. Use the update function instead.';
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
$adminUsername = trim($_POST['admin_username'] ?? '');
|
||||
$adminPassword = trim($_POST['admin_password'] ?? '');
|
||||
$adminEmail = trim($_POST['admin_email'] ?? '');
|
||||
@@ -382,6 +419,7 @@ class InstallerController extends Controller
|
||||
'022_add_pushover_channel_type.sql',
|
||||
'023_update_app_version_to_1.1.1.sql',
|
||||
'024_add_status_notifications_v1.1.2.sql',
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||
@@ -505,6 +543,9 @@ class InstallerController extends Controller
|
||||
*/
|
||||
public function showUpdate()
|
||||
{
|
||||
// Require admin authentication — updates are only for installed systems
|
||||
$this->requireAdmin();
|
||||
|
||||
$pending = $this->getPendingMigrations();
|
||||
|
||||
if (empty($pending)) {
|
||||
@@ -524,6 +565,9 @@ class InstallerController extends Controller
|
||||
*/
|
||||
public function runUpdate()
|
||||
{
|
||||
// Require admin authentication
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/install/update');
|
||||
return;
|
||||
@@ -600,10 +644,12 @@ class InstallerController extends Controller
|
||||
|
||||
// Determine from/to versions based on migrations
|
||||
$fromVersion = '1.0.0';
|
||||
$toVersion = '1.1.2';
|
||||
$toVersion = '1.1.3';
|
||||
|
||||
// Detect version based on which migrations were run
|
||||
if (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) {
|
||||
if (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
|
||||
$toVersion = '1.1.3';
|
||||
} elseif (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) {
|
||||
$toVersion = '1.1.2';
|
||||
} elseif (in_array('022_add_pushover_channel_type.sql', $executed)) {
|
||||
$toVersion = '1.1.1';
|
||||
|
||||
@@ -93,7 +93,7 @@ class NotificationController extends Controller
|
||||
|
||||
$this->notificationModel->markAsRead($notificationId, $userId);
|
||||
|
||||
// If redirect=domain, go to the domain view page
|
||||
// Optional redirect after marking read
|
||||
$redirect = $_GET['redirect'] ?? '';
|
||||
if ($redirect === 'domain') {
|
||||
$domainId = (int)($_GET['domain_id'] ?? 0);
|
||||
@@ -102,6 +102,10 @@ class NotificationController extends Controller
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($redirect === 'settings') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
// AJAX request - return JSON (check multiple detection methods)
|
||||
$isAjax = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')
|
||||
@@ -225,6 +229,7 @@ class NotificationController extends Controller
|
||||
'whois_failed' => 'exclamation-circle',
|
||||
'system_welcome' => 'hand-sparkles',
|
||||
'system_upgrade' => 'arrow-up',
|
||||
'update_available' => 'cloud-download-alt',
|
||||
default => 'bell'
|
||||
};
|
||||
}
|
||||
@@ -247,6 +252,7 @@ class NotificationController extends Controller
|
||||
'whois_failed' => 'gray',
|
||||
'system_welcome' => 'purple',
|
||||
'system_upgrade' => 'indigo',
|
||||
'update_available' => 'blue',
|
||||
default => 'gray'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,332 @@ class NotificationGroupController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export notification groups with channels as CSV or JSON (secrets masked)
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
$logger = new \App\Services\Logger('export');
|
||||
|
||||
try {
|
||||
$userId = \Core\Auth::id();
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
$format = $_GET['format'] ?? 'csv';
|
||||
$logger->info("Groups export started", ['format' => $format, 'user_id' => $userId]);
|
||||
|
||||
if (!in_array($format, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid export format';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get groups
|
||||
if ($isolationMode === 'isolated') {
|
||||
$groups = $this->groupModel->getAllWithChannelCount($userId);
|
||||
} else {
|
||||
$groups = $this->groupModel->getAllWithChannelCount();
|
||||
}
|
||||
|
||||
$exportData = [];
|
||||
foreach ($groups as $group) {
|
||||
$channels = $this->channelModel->getByGroupId($group['id']);
|
||||
$maskedChannels = [];
|
||||
foreach ($channels as $ch) {
|
||||
$config = json_decode($ch['channel_config'], true) ?? [];
|
||||
$maskedConfig = $this->maskChannelConfig($ch['channel_type'], $config);
|
||||
$maskedChannels[] = [
|
||||
'channel_type' => $ch['channel_type'],
|
||||
'channel_config' => $maskedConfig,
|
||||
'is_active' => (bool)$ch['is_active']
|
||||
];
|
||||
}
|
||||
|
||||
$exportData[] = [
|
||||
'group_name' => $group['name'],
|
||||
'group_description' => $group['description'] ?? '',
|
||||
'channels' => $maskedChannels
|
||||
];
|
||||
}
|
||||
|
||||
$date = date('Y-m-d');
|
||||
$filename = "notification_groups_export_{$date}";
|
||||
|
||||
// Clean any prior output buffers to prevent header conflicts
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
if ($format === 'json') {
|
||||
header('Content-Type: application/json');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// Build CSV in memory — flatten groups with channels into rows
|
||||
$csvRows = [];
|
||||
foreach ($exportData as $group) {
|
||||
if (empty($group['channels'])) {
|
||||
$csvRows[] = ['group_name' => $group['group_name'], 'group_description' => $group['group_description'], 'channel_type' => '', 'channel_config' => '', 'is_active' => ''];
|
||||
} else {
|
||||
foreach ($group['channels'] as $ch) {
|
||||
$csvRows[] = [
|
||||
'group_name' => $group['group_name'],
|
||||
'group_description' => $group['group_description'],
|
||||
'channel_type' => $ch['channel_type'],
|
||||
'channel_config' => json_encode($ch['channel_config']),
|
||||
'is_active' => $ch['is_active'] ? '1' : '0'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$csvContent = $this->buildCsv($csvRows, ['group_name', 'group_description', 'channel_type', 'channel_config', 'is_active']);
|
||||
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
|
||||
header('Content-Length: ' . strlen($csvContent));
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo $csvContent;
|
||||
}
|
||||
|
||||
$logger->info("Groups export completed successfully");
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error("Groups export failed", [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
]);
|
||||
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
|
||||
$this->redirect('/groups');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSV string in memory from array data
|
||||
*/
|
||||
private function buildCsv(array $rows, array $headers): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, $headers, ',', '"', '\\');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, array_values($row), ',', '"', '\\');
|
||||
}
|
||||
rewind($handle);
|
||||
$csv = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import notification groups from CSV or JSON file
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/groups');
|
||||
|
||||
$validChannelTypes = ['email', 'telegram', 'discord', 'slack', 'mattermost', 'webhook', 'pushover'];
|
||||
|
||||
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$_SESSION['error'] = 'Please select a valid file to import';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $_FILES['import_file'];
|
||||
|
||||
if ($file['size'] > 2097152) {
|
||||
$_SESSION['error'] = 'File is too large. Maximum size is 2MB';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file['tmp_name']);
|
||||
$userId = \Core\Auth::id();
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
|
||||
$groupsCreated = 0;
|
||||
$channelsCreated = 0;
|
||||
$groupsSkipped = 0;
|
||||
|
||||
if ($ext === 'json') {
|
||||
$parsed = json_decode($content, true);
|
||||
if (!is_array($parsed)) {
|
||||
$_SESSION['error'] = 'Invalid JSON file';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($parsed as $groupData) {
|
||||
$groupName = trim($groupData['group_name'] ?? '');
|
||||
if (empty($groupName)) continue;
|
||||
|
||||
// Check if group already exists
|
||||
$existing = $this->groupModel->findByName($groupName, $isolationMode === 'isolated' ? $userId : null);
|
||||
if ($existing) {
|
||||
$groupsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = $this->groupModel->create([
|
||||
'name' => $groupName,
|
||||
'description' => trim($groupData['group_description'] ?? ''),
|
||||
'user_id' => $isolationMode === 'isolated' ? $userId : null
|
||||
]);
|
||||
|
||||
if ($groupId && !empty($groupData['channels'])) {
|
||||
foreach ($groupData['channels'] as $ch) {
|
||||
$channelType = $ch['channel_type'] ?? '';
|
||||
$config = $ch['channel_config'] ?? [];
|
||||
if (empty($channelType) || !in_array($channelType, $validChannelTypes)) continue;
|
||||
|
||||
// Channels with masked secrets are created as inactive
|
||||
$hasMasked = $this->configHasMaskedValues($config);
|
||||
|
||||
$this->channelModel->create([
|
||||
'notification_group_id' => $groupId,
|
||||
'channel_type' => $channelType,
|
||||
'channel_config' => json_encode($config),
|
||||
'is_active' => $hasMasked ? 0 : ((int)($ch['is_active'] ?? 1))
|
||||
]);
|
||||
$channelsCreated++;
|
||||
}
|
||||
}
|
||||
$groupsCreated++;
|
||||
}
|
||||
} else {
|
||||
// CSV: group rows by group_name
|
||||
$lines = array_filter(explode("\n", $content));
|
||||
$header = null;
|
||||
$csvGroups = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$row = str_getcsv(trim($line), ',', '"', '\\');
|
||||
if (!$header) {
|
||||
$header = array_map('strtolower', array_map('trim', $row));
|
||||
continue;
|
||||
}
|
||||
$item = [];
|
||||
foreach ($header as $i => $col) {
|
||||
$item[$col] = $row[$i] ?? '';
|
||||
}
|
||||
$gName = trim($item['group_name'] ?? '');
|
||||
if (empty($gName)) continue;
|
||||
|
||||
if (!isset($csvGroups[$gName])) {
|
||||
$csvGroups[$gName] = [
|
||||
'description' => trim($item['group_description'] ?? ''),
|
||||
'channels' => []
|
||||
];
|
||||
}
|
||||
$chType = trim($item['channel_type'] ?? '');
|
||||
if (!empty($chType) && in_array($chType, $validChannelTypes)) {
|
||||
$config = json_decode($item['channel_config'] ?? '{}', true) ?: [];
|
||||
$csvGroups[$gName]['channels'][] = [
|
||||
'channel_type' => $chType,
|
||||
'channel_config' => $config,
|
||||
'is_active' => $item['is_active'] ?? '1'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($csvGroups as $gName => $gData) {
|
||||
$existing = $this->groupModel->findByName($gName, $isolationMode === 'isolated' ? $userId : null);
|
||||
if ($existing) {
|
||||
$groupsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = $this->groupModel->create([
|
||||
'name' => $gName,
|
||||
'description' => $gData['description'],
|
||||
'user_id' => $isolationMode === 'isolated' ? $userId : null
|
||||
]);
|
||||
|
||||
if ($groupId) {
|
||||
foreach ($gData['channels'] as $ch) {
|
||||
$config = $ch['channel_config'] ?? [];
|
||||
$hasMasked = $this->configHasMaskedValues($config);
|
||||
|
||||
$this->channelModel->create([
|
||||
'notification_group_id' => $groupId,
|
||||
'channel_type' => $ch['channel_type'],
|
||||
'channel_config' => json_encode($config),
|
||||
'is_active' => $hasMasked ? 0 : ((int)($ch['is_active'] ?? 1))
|
||||
]);
|
||||
$channelsCreated++;
|
||||
}
|
||||
$groupsCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$msg = "{$groupsCreated} group(s) imported ({$channelsCreated} channels)";
|
||||
if ($groupsSkipped > 0) $msg .= ", {$groupsSkipped} skipped (already exist)";
|
||||
$_SESSION['success'] = $msg;
|
||||
$this->redirect('/groups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive values in channel config for export
|
||||
*/
|
||||
private function maskChannelConfig(string $type, array $config): array
|
||||
{
|
||||
$masked = $config;
|
||||
$sensitiveKeys = ['bot_token', 'api_token', 'user_key', 'pushover_api_token', 'pushover_user_key'];
|
||||
$urlKeys = ['webhook_url', 'discord_webhook_url', 'slack_webhook_url', 'mattermost_webhook_url'];
|
||||
|
||||
foreach ($sensitiveKeys as $key) {
|
||||
if (!empty($masked[$key])) {
|
||||
$val = $masked[$key];
|
||||
$masked[$key] = '****' . substr($val, -4);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($urlKeys as $key) {
|
||||
if (!empty($masked[$key])) {
|
||||
$parsed = parse_url($masked[$key]);
|
||||
if ($parsed && isset($parsed['host'])) {
|
||||
$scheme = $parsed['scheme'] ?? 'https';
|
||||
$masked[$key] = "{$scheme}://{$parsed['host']}/****";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Email is not masked
|
||||
return $masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config contains masked placeholder values
|
||||
*/
|
||||
private function configHasMaskedValues(array $config): bool
|
||||
{
|
||||
foreach ($config as $value) {
|
||||
if (is_string($value) && (str_contains($value, '****'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->view('groups/create', [
|
||||
|
||||
@@ -28,6 +28,7 @@ class SettingsController extends Controller
|
||||
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||
$twoFactorSettings = $this->settingModel->getTwoFactorSettings();
|
||||
$isolationSettings = $this->getIsolationSettings();
|
||||
$updateSettings = $this->settingModel->getUpdateSettings();
|
||||
|
||||
// Predefined notification day options
|
||||
$notificationPresets = [
|
||||
@@ -76,6 +77,7 @@ class SettingsController extends Controller
|
||||
'captchaSettings' => $captchaSettings,
|
||||
'twoFactorSettings' => $twoFactorSettings,
|
||||
'isolationSettings' => $isolationSettings,
|
||||
'updateSettings' => $updateSettings,
|
||||
'notificationPresets' => $notificationPresets,
|
||||
'checkIntervalPresets' => $checkIntervalPresets,
|
||||
'statusTriggers' => $statusTriggers,
|
||||
|
||||
@@ -49,15 +49,245 @@ class TagController extends Controller
|
||||
|
||||
$availableColors = $this->tagModel->getAvailableColors();
|
||||
|
||||
// Get users for transfer functionality (admin only)
|
||||
$users = [];
|
||||
if (\Core\Auth::isAdmin()) {
|
||||
$userModel = new \App\Models\User();
|
||||
$users = $userModel->all();
|
||||
}
|
||||
|
||||
$this->view('tags/index', [
|
||||
'tags' => $result['tags'],
|
||||
'pagination' => $result['pagination'],
|
||||
'filters' => $filters,
|
||||
'availableColors' => $availableColors,
|
||||
'isolationMode' => $isolationMode
|
||||
'isolationMode' => $isolationMode,
|
||||
'users' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export user's private tags as CSV or JSON
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
$logger = new \App\Services\Logger('export');
|
||||
|
||||
try {
|
||||
$userId = \Core\Auth::id();
|
||||
$format = $_GET['format'] ?? 'csv';
|
||||
$logger->info("Tags export started", ['format' => $format, 'user_id' => $userId]);
|
||||
|
||||
if (!in_array($format, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid export format';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get only the user's private tags (not global)
|
||||
$allUserTags = $this->tagModel->where('user_id', $userId);
|
||||
usort($allUserTags, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
// Map CSS class to readable color name for export
|
||||
$colorNames = $this->tagModel->getAvailableColors(); // cssClass => 'Name'
|
||||
$tags = array_map(fn($t) => [
|
||||
'name' => $t['name'],
|
||||
'color' => $colorNames[$t['color']] ?? 'Gray',
|
||||
'description' => $t['description'] ?? ''
|
||||
], $allUserTags);
|
||||
|
||||
$logger->info("Tags data prepared", ['count' => count($tags)]);
|
||||
|
||||
$date = date('Y-m-d');
|
||||
$filename = "tags_export_{$date}";
|
||||
|
||||
// Clean any prior output buffers to prevent header conflicts
|
||||
$obLevel = ob_get_level();
|
||||
$logger->debug("Output buffer level before clean", ['ob_level' => $obLevel]);
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
$headersSent = headers_sent($sentFile, $sentLine);
|
||||
$logger->debug("Headers status before sending", [
|
||||
'headers_already_sent' => $headersSent,
|
||||
'sent_file' => $sentFile ?? null,
|
||||
'sent_line' => $sentLine ?? null
|
||||
]);
|
||||
|
||||
if ($format === 'json') {
|
||||
header('Content-Type: application/json');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo json_encode($tags, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// Build CSV in memory to avoid fopen('php://output') issues
|
||||
$csvContent = $this->buildCsv($tags, ['name', 'color', 'description']);
|
||||
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
|
||||
header('Content-Length: ' . strlen($csvContent));
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
echo $csvContent;
|
||||
}
|
||||
|
||||
$logger->info("Tags export completed successfully");
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error("Tags export failed", [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
|
||||
$this->redirect('/tags');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSV string in memory from array data
|
||||
*/
|
||||
private function buildCsv(array $rows, array $headers): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, $headers, ',', '"', '\\');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, array_values($row), ',', '"', '\\');
|
||||
}
|
||||
rewind($handle);
|
||||
$csv = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tags from CSV or JSON file
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/tags');
|
||||
|
||||
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$_SESSION['error'] = 'Please select a valid file to import';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $_FILES['import_file'];
|
||||
|
||||
// Validate file size (1MB max)
|
||||
if ($file['size'] > 1048576) {
|
||||
$_SESSION['error'] = 'File is too large. Maximum size is 1MB';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect format from extension
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['csv', 'json'])) {
|
||||
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file['tmp_name']);
|
||||
$tagsData = [];
|
||||
|
||||
if ($ext === 'json') {
|
||||
$parsed = json_decode($content, true);
|
||||
if (!is_array($parsed)) {
|
||||
$_SESSION['error'] = 'Invalid JSON file';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
$tagsData = $parsed;
|
||||
} else {
|
||||
$lines = array_filter(explode("\n", $content));
|
||||
$header = null;
|
||||
foreach ($lines as $line) {
|
||||
$row = str_getcsv(trim($line), ',', '"', '\\');
|
||||
if (!$header) {
|
||||
$header = array_map('strtolower', array_map('trim', $row));
|
||||
continue;
|
||||
}
|
||||
$item = [];
|
||||
foreach ($header as $i => $col) {
|
||||
$item[$col] = $row[$i] ?? '';
|
||||
}
|
||||
$tagsData[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagsData)) {
|
||||
$_SESSION['error'] = 'No tags found in file';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = \Core\Auth::id();
|
||||
$colorMap = $this->tagModel->getAvailableColors(); // cssClass => 'Name'
|
||||
$availableColorClasses = array_keys($colorMap);
|
||||
// Build reverse map: lowercase name => cssClass (e.g. 'blue' => 'bg-blue-100 ...')
|
||||
$nameToClass = [];
|
||||
foreach ($colorMap as $cssClass => $colorName) {
|
||||
$nameToClass[strtolower($colorName)] = $cssClass;
|
||||
}
|
||||
$defaultColor = $availableColorClasses[0] ?? 'bg-gray-100 text-gray-700 border-gray-300';
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($tagsData as $tagRow) {
|
||||
$name = trim($tagRow['name'] ?? '');
|
||||
if (empty($name) || !preg_match('/^[a-z0-9-]+$/', $name)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists for this user
|
||||
$existing = $this->tagModel->findByName($name, $userId);
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept both color names ("Blue") and raw CSS classes
|
||||
$colorInput = trim($tagRow['color'] ?? '');
|
||||
if (!empty($colorInput)) {
|
||||
if (isset($nameToClass[strtolower($colorInput)])) {
|
||||
// Human-readable name (e.g. "Blue")
|
||||
$color = $nameToClass[strtolower($colorInput)];
|
||||
} elseif (in_array($colorInput, $availableColorClasses)) {
|
||||
// Raw CSS class (backward compatible)
|
||||
$color = $colorInput;
|
||||
} else {
|
||||
$color = $defaultColor;
|
||||
}
|
||||
} else {
|
||||
$color = $defaultColor;
|
||||
}
|
||||
|
||||
$this->tagModel->create([
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'description' => trim($tagRow['description'] ?? ''),
|
||||
'user_id' => $userId
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "{$created} tag(s) imported successfully" . ($skipped > 0 ? ", {$skipped} skipped (already exist or invalid)" : '');
|
||||
$this->redirect('/tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new tag
|
||||
*/
|
||||
@@ -442,12 +672,7 @@ class TagController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
if (!\Core\Csrf::verify($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = 'Invalid request';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
$this->verifyCsrf('/tags');
|
||||
|
||||
$tagIds = $_POST['tag_ids'] ?? [];
|
||||
if (empty($tagIds)) {
|
||||
@@ -496,4 +721,97 @@ class TagController extends Controller
|
||||
|
||||
$this->redirect('/tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer tag to another user (Admin only)
|
||||
*/
|
||||
public function transfer()
|
||||
{
|
||||
\Core\Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/tags');
|
||||
|
||||
$tagId = (int)($_POST['tag_id'] ?? 0);
|
||||
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||
|
||||
if (!$tagId || !$targetUserId) {
|
||||
$_SESSION['error'] = 'Invalid tag or user selected';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$tag = $this->tagModel->find($tagId);
|
||||
if (!$tag) {
|
||||
$_SESSION['error'] = 'Tag not found';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new \App\Models\User();
|
||||
$targetUser = $userModel->find($targetUserId);
|
||||
if (!$targetUser) {
|
||||
$_SESSION['error'] = 'Target user not found';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
|
||||
} else {
|
||||
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
|
||||
}
|
||||
|
||||
$this->redirect('/tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk transfer tags to another user (Admin only)
|
||||
*/
|
||||
public function bulkTransfer()
|
||||
{
|
||||
\Core\Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/tags');
|
||||
|
||||
$tagIds = $_POST['tag_ids'] ?? [];
|
||||
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||
|
||||
if (empty($tagIds) || !$targetUserId) {
|
||||
$_SESSION['error'] = 'No tags selected or invalid user';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new \App\Models\User();
|
||||
$targetUser = $userModel->find($targetUserId);
|
||||
if (!$targetUser) {
|
||||
$_SESSION['error'] = 'Target user not found';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$transferred = 0;
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tagId = (int)$tagId;
|
||||
if ($tagId > 0) {
|
||||
$tag = $this->tagModel->find($tagId);
|
||||
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||
$transferred++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
|
||||
$this->redirect('/tags');
|
||||
}
|
||||
}
|
||||
|
||||
290
app/Controllers/UpdateController.php
Normal file
290
app/Controllers/UpdateController.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Services\UpdateService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Logger;
|
||||
|
||||
class UpdateController extends Controller
|
||||
{
|
||||
private UpdateService $updateService;
|
||||
private Setting $settingModel;
|
||||
private Logger $logger;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Auth::requireAdmin();
|
||||
$this->updateService = new UpdateService();
|
||||
$this->settingModel = new Setting();
|
||||
$this->logger = new Logger('updater');
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Check for updates
|
||||
* POST /api/updates/check
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->json(['error' => 'Method not allowed'], 405);
|
||||
return;
|
||||
}
|
||||
|
||||
$forceCheck = isset($_POST['force']) && $_POST['force'] === '1';
|
||||
$result = $this->updateService->checkForUpdate($forceCheck);
|
||||
|
||||
// When manual check finds an update, create in-app notification for admins (once per version/sha)
|
||||
if (!empty($result['available']) && empty($result['error'])) {
|
||||
$type = $result['type'] ?? 'release';
|
||||
$notifiedRelease = $this->settingModel->getValue('last_update_available_notified_release', '');
|
||||
$notifiedHotfixSha = $this->settingModel->getValue('last_update_available_notified_hotfix_sha', '');
|
||||
$shouldNotify = false;
|
||||
if ($type === 'release') {
|
||||
$latestVersion = $result['latest_version'] ?? '';
|
||||
if ($latestVersion !== '' && $latestVersion !== $notifiedRelease) {
|
||||
$shouldNotify = true;
|
||||
$this->settingModel->setValue('last_update_available_notified_release', $latestVersion);
|
||||
}
|
||||
} else {
|
||||
$remoteSha = $result['remote_sha'] ?? '';
|
||||
if ($remoteSha !== '' && $remoteSha !== $notifiedHotfixSha) {
|
||||
$shouldNotify = true;
|
||||
$this->settingModel->setValue('last_update_available_notified_hotfix_sha', $remoteSha);
|
||||
}
|
||||
}
|
||||
if ($shouldNotify) {
|
||||
try {
|
||||
$notificationService = new NotificationService();
|
||||
$currentVersion = $result['current_version'] ?? '';
|
||||
$label = ($type === 'release') ? ($result['latest_version'] ?? 'latest') : 'hotfix';
|
||||
$commitsBehind = $result['commits_behind'] ?? null;
|
||||
$notificationService->notifyAdminsUpdateAvailable($currentVersion, $label, $type, $commitsBehind);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to send update-available notification', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an update (download, extract, replace files)
|
||||
* POST /settings/updates/apply
|
||||
*/
|
||||
public function apply()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
$this->verifyCsrf('/settings#updates');
|
||||
|
||||
$type = $_POST['update_type'] ?? 'release';
|
||||
|
||||
if (!in_array($type, ['release', 'hotfix'])) {
|
||||
$_SESSION['error'] = 'Invalid update type';
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('Update requested by admin', [
|
||||
'type' => $type,
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
|
||||
$result = $this->updateService->performUpdate($type);
|
||||
|
||||
if ($result['success']) {
|
||||
$fromVersion = $result['from_version'];
|
||||
$toVersion = $result['to_version'] ?? 'latest';
|
||||
$filesUpdated = $result['files_updated'];
|
||||
|
||||
// Check for pending migrations after file update
|
||||
$hasMigrations = $this->updateService->hasPendingMigrations();
|
||||
|
||||
// Notify admins
|
||||
try {
|
||||
$notificationService = new NotificationService();
|
||||
$notificationService->notifyAdminsUpgrade(
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
0,
|
||||
!empty($result['composer_manual_required'])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// Non-critical
|
||||
$this->logger->warning('Failed to send upgrade notification', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$message = "Update applied successfully! {$filesUpdated} file(s) updated.";
|
||||
if (!empty($result['db_backup_warning'])) {
|
||||
$message .= ' Note: Database backup was skipped (' . $result['db_backup_warning'] . '). Consider backing up your database manually.';
|
||||
}
|
||||
if ($hasMigrations) {
|
||||
$message .= ' Database migrations are pending - please run them now.';
|
||||
}
|
||||
if (!empty($result['composer_manual_required'])) {
|
||||
$message .= ' Composer could not be run here (e.g. exec disabled on cPanel). If dependencies changed, run "composer install --no-dev" manually via SSH or Terminal.';
|
||||
}
|
||||
$_SESSION['success'] = $message;
|
||||
if ($hasMigrations) {
|
||||
$this->redirect('/install/update');
|
||||
return;
|
||||
}
|
||||
$this->redirect('/settings#updates');
|
||||
|
||||
} else {
|
||||
$errors = implode('; ', $result['errors']);
|
||||
$_SESSION['error'] = "Update failed: {$errors}";
|
||||
$this->redirect('/settings#updates');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to last backup
|
||||
* POST /settings/updates/rollback
|
||||
*/
|
||||
public function rollback()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
$this->verifyCsrf('/settings#updates');
|
||||
|
||||
$this->logger->info('Rollback requested by admin', [
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
|
||||
$result = $this->updateService->rollback();
|
||||
|
||||
if ($result['success']) {
|
||||
$msg = 'Rollback completed successfully. Files have been restored to the previous version.';
|
||||
if (isset($result['db_restored'])) {
|
||||
$msg .= $result['db_restored']
|
||||
? ' Database has also been restored from the backup.'
|
||||
: ' Database could not be restored automatically. You can import the SQL backup manually from the backups/ directory.';
|
||||
}
|
||||
$_SESSION['success'] = $msg;
|
||||
} else {
|
||||
$_SESSION['error'] = $result['error'] ?? 'Rollback failed';
|
||||
}
|
||||
|
||||
$this->redirect('/settings#updates');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save update preferences (channel + badge) from single form
|
||||
* POST /settings/updates/preferences
|
||||
*/
|
||||
public function savePreferences()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/settings#updates');
|
||||
|
||||
$channel = $_POST['update_channel'] ?? 'stable';
|
||||
if (!in_array($channel, ['stable', 'latest'])) {
|
||||
$_SESSION['error'] = 'Invalid update channel';
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
$badgeEnabled = isset($_POST['update_badge_enabled']) && $_POST['update_badge_enabled'] === '1' ? '1' : '0';
|
||||
|
||||
$this->settingModel->setValue('update_channel', $channel);
|
||||
$this->settingModel->setValue('update_badge_enabled', $badgeEnabled);
|
||||
|
||||
if ($channel === 'latest') {
|
||||
$currentSha = $this->settingModel->getValue('installed_commit_sha', null);
|
||||
if (!$currentSha) {
|
||||
$_SESSION['info'] = 'Update preferences saved. Note: Commit tracking will begin after the first update is applied.';
|
||||
} else {
|
||||
$_SESSION['success'] = 'Update preferences saved.';
|
||||
}
|
||||
} else {
|
||||
$_SESSION['success'] = 'Update preferences saved.';
|
||||
}
|
||||
|
||||
$this->settingModel->setValue('last_update_check', null);
|
||||
$this->redirect('/settings#updates');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the update channel preference
|
||||
* POST /settings/updates/channel
|
||||
*/
|
||||
public function updateChannel()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
$this->verifyCsrf('/settings#updates');
|
||||
|
||||
$channel = $_POST['update_channel'] ?? 'stable';
|
||||
|
||||
if (!in_array($channel, ['stable', 'latest'])) {
|
||||
$_SESSION['error'] = 'Invalid update channel';
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->settingModel->setValue('update_channel', $channel);
|
||||
|
||||
// If switching to "latest" and no commit SHA is tracked, try to fetch it
|
||||
if ($channel === 'latest') {
|
||||
$currentSha = $this->settingModel->getValue('installed_commit_sha', null);
|
||||
if (!$currentSha) {
|
||||
$_SESSION['info'] = 'Update channel set to Latest. Note: Commit tracking will begin after the first update is applied. Until then, only release updates will be detected.';
|
||||
} else {
|
||||
$_SESSION['success'] = 'Update channel set to Latest. You will now receive both releases and hotfix updates.';
|
||||
}
|
||||
} else {
|
||||
$_SESSION['success'] = 'Update channel set to Stable. You will only receive tagged release updates.';
|
||||
}
|
||||
|
||||
// Clear cached check results so next check uses new channel
|
||||
$this->settingModel->setValue('last_update_check', null);
|
||||
|
||||
$this->redirect('/settings#updates');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "show update badge in menu" preference
|
||||
* POST /settings/updates/badge
|
||||
*/
|
||||
public function updateBadgePreference()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/settings#updates');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/settings#updates');
|
||||
|
||||
$enabled = isset($_POST['update_badge_enabled']) && $_POST['update_badge_enabled'] === '1' ? '1' : '0';
|
||||
$this->settingModel->setValue('update_badge_enabled', $enabled);
|
||||
|
||||
$_SESSION['success'] = $enabled === '1'
|
||||
? 'Update badge will be shown in the top menu when an update is available.'
|
||||
: 'Update badge in the top menu is now disabled.';
|
||||
$this->redirect('/settings#updates');
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ class LayoutHelper
|
||||
'whois_failed' => 'exclamation-circle',
|
||||
'system_welcome' => 'hand-sparkles',
|
||||
'system_upgrade' => 'arrow-up',
|
||||
'update_available' => 'cloud-download-alt',
|
||||
default => 'bell'
|
||||
};
|
||||
}
|
||||
@@ -154,6 +155,7 @@ class LayoutHelper
|
||||
'whois_failed' => 'gray',
|
||||
'system_welcome' => 'purple',
|
||||
'system_upgrade' => 'indigo',
|
||||
'update_available' => 'blue',
|
||||
default => 'gray'
|
||||
};
|
||||
}
|
||||
@@ -182,5 +184,47 @@ class LayoutHelper
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update badge info for the top menu (admin only).
|
||||
* Uses cached update check data; no GitHub API call.
|
||||
* Returns ['show' => bool, 'available' => bool, 'label' => string].
|
||||
*/
|
||||
public static function getUpdateBadgeInfo(): array
|
||||
{
|
||||
try {
|
||||
$settingModel = new Setting();
|
||||
$updateSettings = $settingModel->getUpdateSettings();
|
||||
$badgeEnabled = ($updateSettings['update_badge_enabled'] ?? '1') !== '0';
|
||||
if (!$badgeEnabled) {
|
||||
return ['show' => false, 'available' => false, 'label' => ''];
|
||||
}
|
||||
|
||||
$current = $settingModel->getAppVersion();
|
||||
$latestVersion = $updateSettings['latest_available_version'] ?? null;
|
||||
$channel = $updateSettings['update_channel'] ?? 'stable';
|
||||
$commitsBehind = (int) ($updateSettings['commits_behind_count'] ?? 0);
|
||||
|
||||
$available = false;
|
||||
$label = '';
|
||||
|
||||
if ($latestVersion && version_compare($latestVersion, $current, '>')) {
|
||||
$available = true;
|
||||
$label = 'v' . $latestVersion;
|
||||
}
|
||||
if ($channel === 'latest' && $commitsBehind > 0 && !$available) {
|
||||
$available = true;
|
||||
$label = $commitsBehind . ' commit' . ($commitsBehind !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
return [
|
||||
'show' => $available,
|
||||
'available' => $available,
|
||||
'label' => $label,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['show' => false, 'available' => false, 'label' => ''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,37 @@ class ViewHelper
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PHP max upload size from ini settings.
|
||||
* Returns the lower of upload_max_filesize and post_max_size as a human-readable string.
|
||||
*
|
||||
* @return string Human-readable size (e.g. "128 MB")
|
||||
*/
|
||||
public static function getMaxUploadSize(): string
|
||||
{
|
||||
$phpUploadMax = self::parseIniSize(ini_get('upload_max_filesize') ?: '2M');
|
||||
$phpPostMax = self::parseIniSize(ini_get('post_max_size') ?: '8M');
|
||||
$phpLimit = min($phpUploadMax, $phpPostMax);
|
||||
|
||||
return self::formatBytes($phpLimit, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PHP ini size value (e.g. "2M", "128K", "1G") into bytes.
|
||||
*/
|
||||
private static function parseIniSize(string $size): int
|
||||
{
|
||||
$value = (int) $size;
|
||||
$unit = strtolower(substr(trim($size), -1));
|
||||
|
||||
return match ($unit) {
|
||||
'g' => $value * 1073741824,
|
||||
'm' => $value * 1048576,
|
||||
'k' => $value * 1024,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate alert message HTML
|
||||
*/
|
||||
|
||||
@@ -98,5 +98,21 @@ class NotificationGroup extends Model
|
||||
$stmt->execute([$userId]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a notification group by name
|
||||
*/
|
||||
public function findByName(string $name, ?int $userId = null): ?array
|
||||
{
|
||||
if ($userId) {
|
||||
$stmt = $this->db->prepare("SELECT * FROM notification_groups WHERE name = ? AND user_id = ? LIMIT 1");
|
||||
$stmt->execute([$name, $userId]);
|
||||
} else {
|
||||
$stmt = $this->db->prepare("SELECT * FROM notification_groups WHERE name = ? LIMIT 1");
|
||||
$stmt->execute([$name]);
|
||||
}
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class Setting extends Model
|
||||
*/
|
||||
public function getAppVersion(): string
|
||||
{
|
||||
return $this->getValue('app_version', '1.1.2');
|
||||
return $this->getValue('app_version', '1.1.3');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +322,27 @@ class Setting extends Model
|
||||
return $this->setValue('notification_status_triggers', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update settings
|
||||
*/
|
||||
public function getUpdateSettings(): array
|
||||
{
|
||||
return [
|
||||
'update_channel' => $this->getValue('update_channel', 'stable'),
|
||||
'last_update_check' => $this->getValue('last_update_check', null),
|
||||
'latest_available_version' => $this->getValue('latest_available_version', null),
|
||||
'latest_release_notes' => $this->getValue('latest_release_notes', ''),
|
||||
'latest_release_url' => $this->getValue('latest_release_url', ''),
|
||||
'latest_release_published_at' => $this->getValue('latest_release_published_at', ''),
|
||||
'installed_commit_sha' => $this->getValue('installed_commit_sha', null),
|
||||
'update_backup_path' => $this->getValue('update_backup_path', null),
|
||||
'update_db_backup_path' => $this->getValue('update_db_backup_path', null),
|
||||
'commits_behind_count' => (int) $this->getValue('commits_behind_count', 0),
|
||||
'latest_remote_sha' => $this->getValue('latest_remote_sha', ''),
|
||||
'update_badge_enabled' => $this->getValue('update_badge_enabled', '1'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old notification logs
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,7 @@ class ErrorHandler
|
||||
private Logger $logger;
|
||||
private ?ErrorLog $errorLogModel = null;
|
||||
private bool $isDevelopment;
|
||||
private bool $handling = false; // Recursion guard
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -29,9 +30,8 @@ class ErrorHandler
|
||||
// Initialize ErrorLog model if database is available
|
||||
try {
|
||||
$this->errorLogModel = new ErrorLog();
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
// Database not available, will only use file logging
|
||||
// Don't use error_log as it might fail too
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,22 @@ class ErrorHandler
|
||||
*/
|
||||
public function handleException(\Throwable $exception): void
|
||||
{
|
||||
// Prevent infinite recursion if error handling itself triggers an error
|
||||
if ($this->handling) {
|
||||
// Fallback: just log to file and stop
|
||||
try {
|
||||
$this->logger->critical('Recursive error detected', [
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine()
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort
|
||||
}
|
||||
return;
|
||||
}
|
||||
$this->handling = true;
|
||||
|
||||
$errorData = $this->captureError($exception);
|
||||
|
||||
// Log to file
|
||||
@@ -62,8 +78,8 @@ class ErrorHandler
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore certain non-critical errors during error handling itself
|
||||
if (error_reporting() === 0) {
|
||||
// Prevent recursive handling (e.g. if logToDatabase triggers a warning)
|
||||
if ($this->handling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ class ErrorHandler
|
||||
'error_message' => $exception->getMessage(),
|
||||
'error_file' => $exception->getFile(),
|
||||
'error_line' => $exception->getLine(),
|
||||
'stack_trace' => json_encode($exception->getTrace()),
|
||||
'stack_trace' => json_encode($exception->getTrace(), JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[]',
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A',
|
||||
'request_data' => json_encode($requestData),
|
||||
@@ -228,9 +244,19 @@ class ErrorHandler
|
||||
|
||||
try {
|
||||
return $this->errorLogModel->logError($errorData);
|
||||
} catch (\Exception $e) {
|
||||
// Database logging failed, continue with file logging only
|
||||
error_log("Failed to log error to database: " . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
// Database logging failed — log to file so it's visible in the app's /logs folder
|
||||
try {
|
||||
$this->logger->error('Failed to log error to database', [
|
||||
'db_error' => $e->getMessage(),
|
||||
'db_error_file' => $e->getFile(),
|
||||
'db_error_line' => $e->getLine(),
|
||||
'original_error_id' => $errorData['error_id'] ?? 'unknown'
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
// Last resort — use PHP's native error_log
|
||||
error_log("ErrorHandler: DB log failed: " . $e->getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,30 +569,36 @@ class NotificationService
|
||||
|
||||
/**
|
||||
* Create system upgrade notification for admins (in-app)
|
||||
* @param bool $composerManualRequired If true, appends a note to run composer install manually (e.g. when exec is disabled on cPanel)
|
||||
*/
|
||||
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount): void
|
||||
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
|
||||
{
|
||||
$message = "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)";
|
||||
if ($composerManualRequired) {
|
||||
$message .= ". Composer could not be run here (e.g. exec disabled). If dependencies changed, run \"composer install --no-dev\" manually via SSH or Terminal.";
|
||||
}
|
||||
$notificationModel = new \App\Models\Notification();
|
||||
$notificationModel->createNotification(
|
||||
$userId,
|
||||
'system_upgrade',
|
||||
'System Upgraded Successfully',
|
||||
"Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)",
|
||||
$message,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all admins about system upgrade (in-app)
|
||||
* @param bool $composerManualRequired If true, in-app message will include a note to run composer install manually
|
||||
*/
|
||||
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount): void
|
||||
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
|
||||
{
|
||||
try {
|
||||
$userModel = new \App\Models\User();
|
||||
$admins = $userModel->getAllAdmins();
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount);
|
||||
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount, $composerManualRequired);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$logger = new \App\Services\Logger();
|
||||
@@ -602,6 +608,50 @@ class NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create "update available" in-app notification for one user
|
||||
*/
|
||||
public function notifyUpdateAvailable(int $userId, string $currentVersion, string $latestVersion, string $type = 'release', ?int $commitsBehind = null): void
|
||||
{
|
||||
$notificationModel = new \App\Models\Notification();
|
||||
$title = 'Update Available';
|
||||
if ($type === 'release') {
|
||||
$message = "A new version of Domain Monitor is available: v{$latestVersion} (you have v{$currentVersion}). Go to Settings → Updates to apply.";
|
||||
} else {
|
||||
$msg = $commitsBehind
|
||||
? "{$commitsBehind} new commit(s) are available on the main branch. Go to Settings → Updates to apply the hotfix."
|
||||
: "New commits are available. Go to Settings → Updates to apply the hotfix.";
|
||||
$message = $msg;
|
||||
}
|
||||
$notificationModel->createNotification(
|
||||
$userId,
|
||||
'update_available',
|
||||
$title,
|
||||
$message,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all admins that an update is available (in-app)
|
||||
* Used by cron when it detects a new version or hotfix.
|
||||
*/
|
||||
public function notifyAdminsUpdateAvailable(string $currentVersion, string $latestVersionOrLabel, string $type = 'release', ?int $commitsBehind = null): void
|
||||
{
|
||||
try {
|
||||
$userModel = new \App\Models\User();
|
||||
$admins = $userModel->getAllAdmins();
|
||||
foreach ($admins as $admin) {
|
||||
$this->notifyUpdateAvailable($admin['id'], $currentVersion, $latestVersionOrLabel, $type, $commitsBehind);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$logger = new \App\Services\Logger();
|
||||
$logger->error("Failed to notify admins about available update", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old read notifications (cleanup)
|
||||
*/
|
||||
|
||||
1240
app/Services/UpdateService.php
Normal file
1240
app/Services/UpdateService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,21 @@ $pageIcon = 'fas fa-layer-group';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Main Form -->
|
||||
<!-- Main Container -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-layer-group text-gray-400 mr-2 text-sm"></i>
|
||||
Bulk Add Domains
|
||||
</h2>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 bg-gray-50">
|
||||
<button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white transition-colors">
|
||||
<i class="fas fa-keyboard mr-2"></i>Paste Domains
|
||||
</button>
|
||||
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-colors">
|
||||
<i class="fas fa-file-upload mr-2"></i>Import from File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Tab 1: Paste Domains (existing) -->
|
||||
<div id="panel-paste" class="p-6">
|
||||
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Domains Textarea -->
|
||||
@@ -44,10 +48,8 @@ ob_start();
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<!-- Tag Display Area -->
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
|
||||
<!-- Tag Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<input type="text"
|
||||
@@ -60,17 +62,15 @@ ob_start();
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store tags for form submission -->
|
||||
<input type="hidden" id="tags" name="tags" value="">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.
|
||||
All imported domains will be tagged with these tags.
|
||||
</p>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 mb-1.5">Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
@@ -116,11 +116,88 @@ ob_start();
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Import from File -->
|
||||
<div id="panel-import" class="hidden p-6">
|
||||
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Select File *
|
||||
</label>
|
||||
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="domainFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="domainDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1.5">or</p>
|
||||
<span class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-3 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="domainDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="domainFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="domainFileSize"></p>
|
||||
<button type="button" id="domainFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expected Format Info -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-gray-900 mb-2"><i class="fas fa-info-circle text-blue-500 mr-1.5"></i>Expected File Format</p>
|
||||
<p class="text-xs text-gray-600 mb-2">CSV columns or JSON fields:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-blue-200 font-semibold text-blue-800">domain_name *</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">tags</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notes</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notification_group</code>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Only <code class="bg-white px-1 rounded">domain_name</code> is required. Tags should be comma-separated. Notification group is matched by name.</p>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Notification Group -->
|
||||
<div>
|
||||
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Default Notification Group
|
||||
<span class="text-gray-400 font-normal">(for domains without a group in the file)</span>
|
||||
</label>
|
||||
<select id="import_notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit" id="domainImportBtn"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-file-import mr-2"></i>
|
||||
Import Domains
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- end card -->
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- How it works -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -131,14 +208,13 @@ ob_start();
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
Paste multiple domain names, one per line. The system will fetch WHOIS information
|
||||
Paste domain names or upload a CSV/JSON file. The system will fetch WHOIS information
|
||||
for each domain automatically. This may take a few moments depending on how many domains you're adding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important notes -->
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -169,6 +245,23 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
|
||||
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
|
||||
|
||||
const pasteTab = document.getElementById('tab-paste');
|
||||
const importTab = document.getElementById('tab-import');
|
||||
|
||||
[pasteTab, importTab].forEach(btn => {
|
||||
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'border-transparent', 'text-gray-500');
|
||||
});
|
||||
const active = tab === 'paste' ? pasteTab : importTab;
|
||||
const inactive = tab === 'paste' ? importTab : pasteTab;
|
||||
active.classList.add('border-primary', 'text-primary', 'bg-white');
|
||||
inactive.classList.add('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
let tags = [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
@@ -254,6 +347,84 @@ function addTagFromInput() {
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
|
||||
// --- Domain Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('domainDropzone');
|
||||
const fileInput = document.getElementById('domainFileInput');
|
||||
const content = document.getElementById('domainDropzoneContent');
|
||||
const fileInfo = document.getElementById('domainDropzoneFile');
|
||||
const fileName = document.getElementById('domainFileName');
|
||||
const fileSize = document.getElementById('domainFileSize');
|
||||
const removeBtn = document.getElementById('domainFileRemove');
|
||||
const form = document.getElementById('domainImportForm');
|
||||
const submitBtn = document.getElementById('domainImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Importing & Fetching WHOIS...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -29,19 +29,25 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<?php if (!empty($domains)): ?>
|
||||
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>">
|
||||
<?php endforeach; ?>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium" title="Refresh all domains on this page">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh Page (<?= count($domains) ?>)
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="domainExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('domainExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
|
||||
<div id="domainExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/domains/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/domains/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
Bulk Add
|
||||
</a>
|
||||
@@ -124,126 +130,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh Selected
|
||||
</button>
|
||||
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
<i class="fas fa-tags mr-2"></i>
|
||||
Manage Tags
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
|
||||
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Manage Tags
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Tags Section -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Add Custom Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Tags Section -->
|
||||
<div class="border-t border-gray-200 pt-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
|
||||
<div class="space-y-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
|
||||
<i class="fas fa-minus mr-1"></i>
|
||||
Remove Specific Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-bell mr-2"></i>
|
||||
Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
@@ -271,6 +158,110 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
|
||||
<!-- Domains List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when domains are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh Selected
|
||||
</button>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-tags mr-1"></i> Manage Tags
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
|
||||
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Manage Tags
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Add Custom Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
|
||||
<div class="space-y-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
|
||||
<i class="fas fa-minus mr-1"></i>
|
||||
Remove Specific Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-bell mr-1"></i> Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($domains)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
@@ -575,11 +566,9 @@ function updateBulkActions() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} domain(s) selected`;
|
||||
selectedCount.textContent = count + ' domain(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -793,7 +782,7 @@ function bulkTransfer() {
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
|
||||
Transfer Domains
|
||||
</button>
|
||||
</div>
|
||||
@@ -1071,6 +1060,14 @@ function submitTagRemoval() {
|
||||
|
||||
// Tags are loaded server-side, no need for DOMContentLoaded
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('domainExportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -121,25 +121,6 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when errors are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -167,6 +148,22 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
|
||||
|
||||
<!-- Errors List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when errors are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -543,11 +540,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
@@ -7,8 +7,31 @@ ob_start();
|
||||
?>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-4 flex justify-end">
|
||||
<a href="/groups/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="groupExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('groupExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="groupExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/groups/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/groups/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Button -->
|
||||
<button onclick="document.getElementById('groupImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
<a href="/groups/create" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create New Group
|
||||
</a>
|
||||
@@ -31,34 +54,29 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($groups)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
@@ -208,11 +226,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' group(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -283,30 +299,29 @@ function transferGroup(groupId, groupName) {
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h3 class="text-lg font-semibold mb-4">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user:</p>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="group_id" value="${groupId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
@@ -319,8 +334,8 @@ function transferGroup(groupId, groupName) {
|
||||
|
||||
// Bulk transfer groups
|
||||
function bulkTransfer() {
|
||||
const selectedCheckboxes = document.querySelectorAll('input[name="group_ids[]"]:checked');
|
||||
if (selectedCheckboxes.length === 0) {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
if (groupIds.length === 0) {
|
||||
alert('Please select groups to transfer');
|
||||
return;
|
||||
}
|
||||
@@ -337,32 +352,31 @@ function bulkTransfer() {
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h3 class="text-lg font-semibold mb-4">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${selectedCheckboxes.length} selected group(s) to another user:</p>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
${Array.from(selectedCheckboxes).map(cb =>
|
||||
`<input type="hidden" name="group_ids[]" value="${cb.value}">`
|
||||
${groupIds.map(id =>
|
||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
||||
).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,6 +386,159 @@ function bulkTransfer() {
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('groupExportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('groupExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close import modal on backdrop click
|
||||
document.getElementById('groupImportModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
this.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
||||
</h3>
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label>
|
||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="groupDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="groupDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="groupFileSize"></p>
|
||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600">CSV: <code class="bg-white px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Group Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('groupDropzone');
|
||||
const fileInput = document.getElementById('groupFileInput');
|
||||
const content = document.getElementById('groupDropzoneContent');
|
||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
||||
const fileName = document.getElementById('groupFileName');
|
||||
const fileSize = document.getElementById('groupFileSize');
|
||||
const removeBtn = document.getElementById('groupFileRemove');
|
||||
const form = document.getElementById('groupImportForm');
|
||||
const submitBtn = document.getElementById('groupImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -12,9 +12,12 @@ if ($userId) {
|
||||
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
|
||||
$recentNotifications = $notificationData['items'];
|
||||
$unreadNotifications = $notificationData['unread_count'];
|
||||
// Update badge in top menu (admin only, uses cached update check data)
|
||||
$updateBadge = \Core\Auth::isAdmin() ? \App\Helpers\LayoutHelper::getUpdateBadgeInfo() : ['show' => false, 'available' => false, 'label' => ''];
|
||||
} else {
|
||||
$recentNotifications = [];
|
||||
$unreadNotifications = 0;
|
||||
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
|
||||
}
|
||||
|
||||
// Get domain stats for sidebar (available on all pages)
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
|
||||
<!-- Right: Actions & User -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-2">
|
||||
<!-- Update available badge (admin only, when enabled in settings) -->
|
||||
<?php if (!empty($updateBadge['show'])): ?>
|
||||
<a href="/settings#updates" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition-colors text-xs font-semibold whitespace-nowrap" title="An update is available">
|
||||
<i class="fas fa-cloud-download-alt"></i>
|
||||
<span>Update<?= !empty($updateBadge['label']) ? ' ' . htmlspecialchars($updateBadge['label']) : '' ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Actions Dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleQuickActions()" title="Quick Actions" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
|
||||
@@ -115,11 +123,15 @@
|
||||
<?php if (!empty($recentNotifications)): ?>
|
||||
<?php foreach ($recentNotifications as $notif): ?>
|
||||
<?php
|
||||
// Build the click URL: if domain notification, go to domain; otherwise just mark as read
|
||||
// Build the click URL: update_available → settings#updates; domain → domain page; else mark as read only
|
||||
$hasDomain = !empty($notif['domain_id']);
|
||||
$notifUrl = $hasDomain
|
||||
? '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id']
|
||||
: '/notifications/' . $notif['id'] . '/mark-read';
|
||||
if ($notif['type'] === 'update_available') {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=settings';
|
||||
} elseif ($hasDomain) {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id'];
|
||||
} else {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read';
|
||||
}
|
||||
?>
|
||||
<div class="px-4 py-3 hover:bg-gray-50 border-b border-gray-100 bg-blue-50 transition-colors notification-item" data-id="<?= $notif['id'] ?>">
|
||||
<div class="flex items-start space-x-3">
|
||||
|
||||
@@ -68,6 +68,7 @@ $offset = $pagination['showing_from'] - 1;
|
||||
<option value="session_failed" <?= $filterType === 'session_failed' ? 'selected' : '' ?>>Failed Login</option>
|
||||
<option value="system_welcome" <?= $filterType === 'system_welcome' ? 'selected' : '' ?>>Welcome</option>
|
||||
<option value="system_upgrade" <?= $filterType === 'system_upgrade' ? 'selected' : '' ?>>System Upgrade</option>
|
||||
<option value="update_available" <?= $filterType === 'update_available' ? 'selected' : '' ?>>Update Available</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
@@ -137,7 +138,9 @@ $offset = $pagination['showing_from'] - 1;
|
||||
$hasDomain = !empty($notification['domain_id']);
|
||||
$domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null;
|
||||
$clickUrl = null;
|
||||
if ($hasDomain && !$notification['is_read']) {
|
||||
if ($notification['type'] === 'update_available') {
|
||||
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=settings';
|
||||
} elseif ($hasDomain && !$notification['is_read']) {
|
||||
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=domain&domain_id=' . $notification['domain_id'];
|
||||
} elseif ($hasDomain) {
|
||||
$clickUrl = $domainUrl;
|
||||
|
||||
@@ -30,6 +30,37 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached update state for Updates tab (tab badge + modal on load)
|
||||
$cachedUpdateAvailable = false;
|
||||
$cachedUpdateData = null;
|
||||
$currentVer = $appSettings['app_version'] ?? '0';
|
||||
$latestVer = $updateSettings['latest_available_version'] ?? null;
|
||||
$updateChannel = $updateSettings['update_channel'] ?? 'stable';
|
||||
$commitsBehind = (int)($updateSettings['commits_behind_count'] ?? 0);
|
||||
if ($latestVer && version_compare($latestVer, $currentVer, '>')) {
|
||||
$cachedUpdateAvailable = true;
|
||||
$cachedUpdateData = [
|
||||
'available' => true,
|
||||
'type' => 'release',
|
||||
'current_version' => $currentVer,
|
||||
'latest_version' => $latestVer,
|
||||
'release_notes' => $updateSettings['latest_release_notes'] ?? '',
|
||||
'release_url' => $updateSettings['latest_release_url'] ?? '',
|
||||
'published_at' => $updateSettings['latest_release_published_at'] ?? null,
|
||||
'channel' => $updateChannel,
|
||||
];
|
||||
} elseif ($updateChannel === 'latest' && $commitsBehind > 0) {
|
||||
$cachedUpdateAvailable = true;
|
||||
$cachedUpdateData = [
|
||||
'available' => true,
|
||||
'type' => 'hotfix',
|
||||
'current_version' => $currentVer,
|
||||
'commits_behind' => $commitsBehind,
|
||||
'commit_messages' => [],
|
||||
'channel' => $updateChannel,
|
||||
];
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
@@ -64,6 +95,15 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
<i class="fas fa-tools mr-2"></i>
|
||||
Maintenance
|
||||
</button>
|
||||
<button onclick="switchTab('updates')" id="tab-updates" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap relative">
|
||||
<i class="fas fa-cloud-download-alt mr-2"></i>
|
||||
Updates
|
||||
<?php if (!empty($cachedUpdateAvailable)): ?>
|
||||
<span id="update-badge" class="ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full" title="Update available"></span>
|
||||
<?php else: ?>
|
||||
<span id="update-badge" class="hidden ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full"></span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -892,6 +932,243 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Updates -->
|
||||
<div id="content-updates" class="tab-content hidden">
|
||||
<!-- Update Status card: current version + Check button; show "Update available" card when cached -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
|
||||
<div class="px-6 py-5 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<i class="fas fa-sync-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Update Status</h3>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Current version <code class="text-primary font-medium">v<?= htmlspecialchars($appSettings['app_version']) ?></code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<?php if ($updateSettings['last_update_check']): ?>
|
||||
<span class="text-xs text-gray-400 hidden sm:inline">Last checked: <?= date('M d, H:i', strtotime($updateSettings['last_update_check'])) ?></span>
|
||||
<?php endif; ?>
|
||||
<button type="button" id="checkUpdatesBtn" onclick="checkForUpdates()"
|
||||
class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-dark transition-colors shadow-sm">
|
||||
<i class="fas fa-sync-alt mr-2" id="checkUpdatesIcon"></i>
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Cached update available: show card that opens modal (so landing from top bar shows update without clicking Check) -->
|
||||
<?php if ($cachedUpdateAvailable && $cachedUpdateData): ?>
|
||||
<div id="cachedUpdateCard" class="mb-6 p-4 rounded-xl border-2 border-amber-200 bg-amber-50/80 hover:bg-amber-50 transition-colors cursor-pointer" onclick="openUpdateModal(window.__cachedUpdateData)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
|
||||
<i class="fas fa-cloud-download-alt text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-900">Update available</p>
|
||||
<p class="text-xs text-amber-700">
|
||||
<?php if (($cachedUpdateData['type'] ?? '') === 'release'): ?>
|
||||
New release: v<?= htmlspecialchars($cachedUpdateData['latest_version'] ?? '') ?> — click to view details and apply
|
||||
<?php else: ?>
|
||||
<?= (int)($cachedUpdateData['commits_behind'] ?? 0) ?> new commit(s) — click to apply hotfix
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-amber-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!empty($updateSettings['last_update_check'])): ?>
|
||||
<!-- Last check found no update: show "up to date" from cache -->
|
||||
<div id="cachedUpToDate">
|
||||
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
|
||||
<p class="text-xs text-green-600 mt-0.5">Version v<?= htmlspecialchars($appSettings['app_version']) ?> is the latest<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? ' stable release' : ' version' ?>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Inline result: loading / up-to-date / error (update-available goes in modal) -->
|
||||
<div id="updateResultContainer" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update preferences (channel + badge) -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Update Preferences</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Choose update channel and display options</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/updates/preferences" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Update Channel</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Stable Channel -->
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
|
||||
<input type="radio" name="update_channel" value="stable"
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt text-green-600 mr-2"></i>
|
||||
Stable
|
||||
</span>
|
||||
<p class="text-xs text-gray-600 mt-1">Only receive tagged release updates (e.g., v1.2.0). Recommended for production environments.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Latest Channel -->
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
|
||||
<input type="radio" name="update_channel" value="latest"
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-bolt text-amber-500 mr-2"></i>
|
||||
Latest
|
||||
</span>
|
||||
<p class="text-xs text-gray-600 mt-1">Receive both releases and hotfix commits pushed to the main branch. Get fixes faster.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (($updateSettings['update_channel'] ?? 'stable') === 'latest' && empty($updateSettings['installed_commit_sha'])): ?>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Commit tracking is not yet active. It will begin after the first update is applied through this system. Until then, only release updates will be detected.
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Show update badge in top menu -->
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input type="checkbox" name="update_badge_enabled" value="1"
|
||||
<?= ($updateSettings['update_badge_enabled'] ?? '1') !== '0' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary mt-0.5">
|
||||
<span class="ml-3 text-sm text-gray-700">
|
||||
Show <strong>Update available</strong> badge in the top menu when an update is available (recommended so admins see it without opening the notification panel).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save update preferences
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Rollback Section -->
|
||||
<?php if (!empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Rollback</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Revert to the previous version if something went wrong</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Warning</p>
|
||||
<p class="text-sm text-gray-700 mt-1">
|
||||
Rolling back will restore application files and database to the state before the last update.
|
||||
If the database restore fails automatically, you can import the SQL backup manually from the <code class="text-xs bg-gray-100 px-1 rounded">backups/</code> directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-sm text-gray-600">
|
||||
Backup available: <code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded"><?= htmlspecialchars(basename($updateSettings['update_backup_path'])) ?></code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
||||
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
Rollback to Previous Version
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Update Available Modal: same content as before (blue/amber card inside), just in a popup -->
|
||||
<div id="updateAvailableModal" class="fixed inset-0 z-50 hidden" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" onclick="closeUpdateModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div id="updateAvailableModalContent" class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col pointer-events-auto overflow-hidden">
|
||||
<div class="flex items-start justify-between px-4 py-3 flex-shrink-0 gap-3 bg-blue-50 border border-blue-200 rounded-t-xl">
|
||||
<div class="flex items-start min-w-0">
|
||||
<i id="updateModalIcon" class="fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p id="updateModalTitle" class="text-sm font-semibold text-blue-800">New Release Available</p>
|
||||
<p id="updateModalSubline" class="text-xs text-blue-600 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<a id="updateModalReleaseLink" href="#" target="_blank" rel="noopener" class="text-xs text-blue-600 hover:underline whitespace-nowrap"><i class="fab fa-github mr-1"></i>Release notes</a>
|
||||
<button type="button" onclick="closeUpdateModal()" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" aria-label="Close">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="updateModalBody" class="update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0">
|
||||
<!-- Filled by JS: changelog or commit list only -->
|
||||
</div>
|
||||
<div id="updateModalFooter" class="px-4 py-3 flex-shrink-0 border-t rounded-b-xl">
|
||||
<!-- Filled by JS: Apply form (blue for release, amber for hotfix) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__cachedUpdateData = <?= $cachedUpdateData ? json_encode($cachedUpdateData) : 'null' ?>;
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
|
||||
<style>
|
||||
.changelog-markdown { line-height: 1.5; }
|
||||
.changelog-markdown h1, .changelog-markdown h2, .changelog-markdown h3 { font-weight: 600; margin-top: 0.5em; margin-bottom: 0.25em; }
|
||||
.changelog-markdown h1 { font-size: 1em; }
|
||||
.changelog-markdown h2 { font-size: 0.95em; }
|
||||
.changelog-markdown h3 { font-size: 0.9em; }
|
||||
.changelog-markdown p { margin-bottom: 0.5em; }
|
||||
.changelog-markdown ul, .changelog-markdown ol { margin: 0.25em 0 0.5em 1em; padding-left: 1em; }
|
||||
.changelog-markdown li { margin-bottom: 0.15em; }
|
||||
.changelog-markdown code { background: rgba(0,0,0,0.06); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.95em; }
|
||||
.changelog-markdown pre { background: rgba(0,0,0,0.06); padding: 0.5em; border-radius: 4px; overflow-x: auto; margin: 0.5em 0; font-size: 0.9em; }
|
||||
.changelog-markdown pre code { background: none; padding: 0; }
|
||||
.changelog-markdown a { color: #2563eb; text-decoration: underline; }
|
||||
.changelog-markdown a:hover { color: #1d4ed8; }
|
||||
.changelog-markdown hr { border: none; border-top: 1px solid rgba(0,0,0,0.1); margin: 0.5em 0; }
|
||||
.changelog-markdown blockquote { border-left: 3px solid rgba(0,0,0,0.15); margin: 0.5em 0; padding-left: 0.75em; color: inherit; opacity: 0.95; }
|
||||
.update-modal-body .changelog-markdown { max-height: 20rem; }
|
||||
</style>
|
||||
<script>
|
||||
// Auto-update encryption based on port
|
||||
function updateEncryptionByPort() {
|
||||
@@ -957,7 +1234,7 @@ function switchTab(tabName) {
|
||||
// Load tab from URL hash on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const hash = window.location.hash.substring(1); // Remove the #
|
||||
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance'];
|
||||
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance', 'updates'];
|
||||
|
||||
if (hash && validTabs.includes(hash)) {
|
||||
switchTab(hash);
|
||||
@@ -1021,6 +1298,208 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Update check AJAX functionality
|
||||
function checkForUpdates() {
|
||||
const btn = document.getElementById('checkUpdatesBtn');
|
||||
const icon = document.getElementById('checkUpdatesIcon');
|
||||
const container = document.getElementById('updateResultContainer');
|
||||
const badge = document.getElementById('update-badge');
|
||||
|
||||
// Hide any cached status cards
|
||||
var cachedCard = document.getElementById('cachedUpdateCard');
|
||||
var cachedUpToDate = document.getElementById('cachedUpToDate');
|
||||
if (cachedCard) cachedCard.classList.add('hidden');
|
||||
if (cachedUpToDate) cachedUpToDate.classList.add('hidden');
|
||||
|
||||
// Show loading state
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-75');
|
||||
icon.classList.add('fa-spin');
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<div class="flex items-center p-4 bg-gray-50 rounded-lg border border-gray-200"><i class="fas fa-spinner fa-spin text-primary mr-3"></i><span class="text-sm text-gray-600">Checking for updates...</span></div>';
|
||||
|
||||
// Get CSRF token
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput ? csrfInput.value : '';
|
||||
|
||||
fetch('/api/updates/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'force=1&csrf_token=' + encodeURIComponent(csrfToken)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-75');
|
||||
icon.classList.remove('fa-spin');
|
||||
|
||||
if (data.error) {
|
||||
container.innerHTML = renderUpdateError(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.available) {
|
||||
badge.classList.remove('hidden');
|
||||
container.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
openUpdateModal(data);
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
container.innerHTML = renderUpToDate(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-75');
|
||||
icon.classList.remove('fa-spin');
|
||||
container.innerHTML = renderUpdateError('Network error: ' + error.message);
|
||||
});
|
||||
}
|
||||
window.checkForUpdates = checkForUpdates;
|
||||
|
||||
function openUpdateModal(data) {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
const bodyEl = document.getElementById('updateModalBody');
|
||||
const footerEl = document.getElementById('updateModalFooter');
|
||||
const titleEl = document.getElementById('updateModalTitle');
|
||||
const sublineEl = document.getElementById('updateModalSubline');
|
||||
const releaseLinkEl = document.getElementById('updateModalReleaseLink');
|
||||
const iconEl = document.getElementById('updateModalIcon');
|
||||
if (!modal || !bodyEl) return;
|
||||
var isRelease = (data.type || 'release') === 'release';
|
||||
if (titleEl) titleEl.textContent = isRelease ? 'New Release Available: v' + (data.latest_version || '') : 'Hotfix Available: ' + (data.commits_behind || 0) + ' commit(s) behind';
|
||||
if (sublineEl) {
|
||||
if (isRelease) {
|
||||
var sub = 'Installed: v' + (data.current_version || '');
|
||||
if (data.published_at) { var d = new Date(data.published_at); sub += ' · Released: ' + String(d.getDate()).padStart(2,'0') + '/' + String(d.getMonth()+1).padStart(2,'0') + '/' + d.getFullYear(); }
|
||||
sublineEl.textContent = sub;
|
||||
sublineEl.classList.remove('hidden');
|
||||
} else {
|
||||
sublineEl.textContent = 'New commits have been pushed to the main branch.';
|
||||
sublineEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
if (releaseLinkEl) {
|
||||
if (isRelease && data.release_url) {
|
||||
releaseLinkEl.href = data.release_url;
|
||||
releaseLinkEl.classList.remove('hidden');
|
||||
} else {
|
||||
releaseLinkEl.href = '#';
|
||||
releaseLinkEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (iconEl) iconEl.className = isRelease ? 'fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3' : 'fas fa-wrench text-amber-500 text-lg mt-0.5 mr-3';
|
||||
bodyEl.className = 'update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0 ' + (isRelease ? 'bg-blue-50 border-x border-b border-blue-200' : 'bg-amber-50 border-x border-b border-amber-200');
|
||||
bodyEl.innerHTML = renderUpdateAvailable(data);
|
||||
var csrf = document.querySelector('input[name=csrf_token]') ? document.querySelector('input[name=csrf_token]').value : '';
|
||||
if (footerEl) {
|
||||
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 border-blue-200' : 'bg-amber-100 border-amber-200');
|
||||
if (isRelease) {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\')">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="release">' +
|
||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
||||
'</form>';
|
||||
} else {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="hotfix">' +
|
||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
||||
'</form>';
|
||||
}
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeUpdateModal() {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
if (modal && !modal.classList.contains('hidden')) closeUpdateModal();
|
||||
}
|
||||
});
|
||||
window.openUpdateModal = openUpdateModal;
|
||||
window.closeUpdateModal = closeUpdateModal;
|
||||
|
||||
// When landing on Settings#updates from an external link (e.g. top bar badge or notification),
|
||||
// auto-open the modal. Skip if the user just submitted a form on this page (referrer is self).
|
||||
if (window.location.hash === '#updates' && window.__cachedUpdateData) {
|
||||
var ref = document.referrer || '';
|
||||
var onSettingsPage = ref.indexOf('/settings') !== -1;
|
||||
if (!onSettingsPage) {
|
||||
setTimeout(function() { openUpdateModal(window.__cachedUpdateData); }, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReleaseNotesMarkdown(md) {
|
||||
if (!md) return '';
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') return escapeHtml(md);
|
||||
const raw = marked.parse(md, { gfm: true, breaks: true });
|
||||
const allowedTags = ['p', 'br', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr'];
|
||||
let out = DOMPurify.sanitize(raw, { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: ['href', 'target', 'rel'] });
|
||||
out = out.replace(/<a href=/gi, '<a target="_blank" rel="noopener noreferrer" href=');
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderUpToDate(data) {
|
||||
return `
|
||||
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
|
||||
<p class="text-xs text-green-600 mt-0.5">Version ${escapeHtml(data.current_version)} is the latest${data.channel === 'stable' ? ' stable release' : ' version'}.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUpdateAvailable(data) {
|
||||
var html = '';
|
||||
if (data.type === 'release') {
|
||||
html = data.release_notes
|
||||
? '<p class="text-sm font-semibold text-blue-800 mb-2 -mt-1">Changelog:</p><hr class="border-blue-200 mb-3"><div class="changelog-markdown text-xs text-blue-700 max-h-40 overflow-y-auto">' + renderReleaseNotesMarkdown(data.release_notes) + '</div>'
|
||||
: '<p class="text-gray-500 text-sm">No changelog available.</p>';
|
||||
} else if (data.type === 'hotfix') {
|
||||
if (data.commit_messages && data.commit_messages.length > 0) {
|
||||
html = '<p class="text-xs font-semibold text-amber-800 mb-2">Recent commits:</p><div class="space-y-1 max-h-40 overflow-y-auto">';
|
||||
data.commit_messages.forEach(function(c) {
|
||||
var firstLine = (c.message || '').split('\n')[0];
|
||||
html += '<div class="text-xs text-amber-700 flex items-start"><code class="text-amber-600 bg-amber-100 px-1 rounded mr-2 flex-shrink-0">' + escapeHtml((c.sha || '').substring(0, 7)) + '</code><span class="truncate">' + escapeHtml(firstLine) + '</span></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html = '';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderUpdateError(message) {
|
||||
return `
|
||||
<div class="flex items-center p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-800">Update check failed</p>
|
||||
<p class="text-xs text-red-600 mt-0.5">${escapeHtml(message)}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Ensure escapeHtml is available (may also be defined in base layout)
|
||||
if (typeof window.escapeHtml === 'undefined') {
|
||||
window.escapeHtml = function(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
}
|
||||
|
||||
// CAPTCHA provider selection logic
|
||||
const captchaProvider = document.getElementById('captcha_provider');
|
||||
if (captchaProvider) {
|
||||
|
||||
@@ -29,6 +29,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="exportDropdownWrapper">
|
||||
<button onclick="document.getElementById('exportDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="exportDropdownMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/tags/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/tags/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Button -->
|
||||
<button onclick="document.getElementById('importModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
<button onclick="openCreateModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create New Tag
|
||||
@@ -105,27 +128,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when tags are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDeleteTags()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when tags are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransferTags()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" onclick="bulkDeleteTags()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($tags)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
@@ -189,6 +214,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
<a href="/tags/<?= $tag['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
|
||||
<!-- Global tag - only admins can edit/delete -->
|
||||
<span class="text-xs text-gray-500 italic">Global tag</span>
|
||||
@@ -197,7 +228,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
|
||||
class="text-red-600 hover:text-red-800" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -242,6 +273,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
|
||||
<!-- Global tag - only admins can edit/delete -->
|
||||
<span class="text-xs text-gray-500 italic">Global tag</span>
|
||||
@@ -250,7 +287,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
|
||||
class="text-red-600 hover:text-red-800" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -466,6 +503,64 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Tags
|
||||
</h3>
|
||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label>
|
||||
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tagDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="tagDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="tagFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="tagFileSize"></p>
|
||||
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600">CSV columns: <code class="bg-white px-1 rounded">name, color, description</code></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('importModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Multi-select functionality
|
||||
function toggleSelectAll(checkbox) {
|
||||
@@ -488,11 +583,9 @@ function updateBulkActions() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} tag(s) selected`;
|
||||
selectedCount.textContent = count + ' tag(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -552,6 +645,109 @@ function bulkDeleteTags() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function transferTag(tagId, tagName) {
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
|
||||
).join('');
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tag</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="tag_id" value="${tagId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Delegate click for table/card Transfer buttons (avoids onclick quote issues)
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferTag(parseInt(btn.dataset.tagId, 10), btn.dataset.tagName || '');
|
||||
}
|
||||
});
|
||||
|
||||
function bulkTransferTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
alert('Please select tags to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tags</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('hidden');
|
||||
document.getElementById('create_name').focus();
|
||||
@@ -618,6 +814,98 @@ document.getElementById('editModal').addEventListener('click', function(e) {
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('importModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
document.getElementById('importModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('exportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('exportDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tagDropzone');
|
||||
const fileInput = document.getElementById('tagFileInput');
|
||||
const content = document.getElementById('tagDropzoneContent');
|
||||
const fileInfo = document.getElementById('tagDropzoneFile');
|
||||
const fileName = document.getElementById('tagFileName');
|
||||
const fileSize = document.getElementById('tagFileSize');
|
||||
const removeBtn = document.getElementById('tagFileRemove');
|
||||
const form = document.getElementById('tagImportForm');
|
||||
const submitBtn = document.getElementById('tagImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -37,7 +37,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="check_updates">
|
||||
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary hover:bg-primary-dark' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Check Updates
|
||||
</button>
|
||||
@@ -45,7 +45,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
@@ -194,32 +194,29 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Admin Only - Hidden by default, shown when TLDs are selected) -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- TLD Registry Table -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<!-- Bulk Actions Bar (shown when TLDs are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($tlds)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
@@ -550,11 +547,9 @@ function updateSelectedCount() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} TLD(s) selected`;
|
||||
selectedCount.textContent = count + ' TLD(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
@@ -355,7 +355,7 @@ ob_start();
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors text-sm">
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
@@ -95,35 +95,6 @@ $pagination = $pagination ?? [
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-user-check mr-2"></i>
|
||||
Activate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700 transition-colors font-medium">
|
||||
<i class="fas fa-user-slash mr-2"></i>
|
||||
Deactivate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -152,6 +123,28 @@ $pagination = $pagination ?? [
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-check mr-1"></i> Activate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-1.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-slash mr-1"></i> Deactivate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($users)): ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -426,11 +419,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' user(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\WhoisService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\UpdateService;
|
||||
use Core\Database;
|
||||
|
||||
// Load environment variables
|
||||
@@ -857,6 +858,42 @@ if ($stats['domains_with_notifications'] > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for application updates (respects 6-hour cache) and notify admins if new version available
|
||||
try {
|
||||
$updateService = new UpdateService();
|
||||
$updateResult = $updateService->checkForUpdate(false);
|
||||
if (!empty($updateResult['error'])) {
|
||||
logMessage("Update check skipped or failed: " . $updateResult['error']);
|
||||
} elseif (!empty($updateResult['available'])) {
|
||||
$currentVersion = $updateResult['current_version'];
|
||||
$type = $updateResult['type'] ?? 'release';
|
||||
$notifiedRelease = $settingModel->getValue('last_update_available_notified_release', '');
|
||||
$notifiedHotfixSha = $settingModel->getValue('last_update_available_notified_hotfix_sha', '');
|
||||
$shouldNotify = false;
|
||||
if ($type === 'release') {
|
||||
$latestVersion = $updateResult['latest_version'] ?? '';
|
||||
if ($latestVersion !== '' && $latestVersion !== $notifiedRelease) {
|
||||
$shouldNotify = true;
|
||||
$settingModel->setValue('last_update_available_notified_release', $latestVersion);
|
||||
}
|
||||
} else {
|
||||
$remoteSha = $updateResult['remote_sha'] ?? '';
|
||||
if ($remoteSha !== '' && $remoteSha !== $notifiedHotfixSha) {
|
||||
$shouldNotify = true;
|
||||
$settingModel->setValue('last_update_available_notified_hotfix_sha', $remoteSha);
|
||||
}
|
||||
}
|
||||
if ($shouldNotify) {
|
||||
$label = ($type === 'release') ? ($updateResult['latest_version'] ?? 'latest') : 'hotfix';
|
||||
$commitsBehind = $updateResult['commits_behind'] ?? null;
|
||||
$notificationService->notifyAdminsUpdateAvailable($currentVersion, $label, $type, $commitsBehind);
|
||||
logMessage("Update available (v{$currentVersion} → {$label}): admins notified.");
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
logMessage("Update check error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
logMessage("==========================\n");
|
||||
|
||||
exit(0);
|
||||
|
||||
@@ -362,7 +362,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||
('app_name', 'Domain Monitor', 'string', 'Application name'),
|
||||
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
|
||||
('app_timezone', 'UTC', 'string', 'Application timezone'),
|
||||
('app_version', '1.1.2', 'string', 'Application version number'),
|
||||
('app_version', '1.1.3', 'string', 'Application version number'),
|
||||
|
||||
-- Email settings
|
||||
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),
|
||||
@@ -395,7 +395,11 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||
('two_factor_email_code_expiry_minutes', '10', 'string', 'Email code expiry time in minutes'),
|
||||
|
||||
-- User isolation settings
|
||||
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)')
|
||||
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'),
|
||||
|
||||
-- Update system settings
|
||||
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
|
||||
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')
|
||||
|
||||
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||
|
||||
|
||||
19
database/migrations/025_add_update_system_v1.1.3.sql
Normal file
19
database/migrations/025_add_update_system_v1.1.3.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: Add application update system settings
|
||||
-- Version: 1.1.3
|
||||
-- This migration adds settings for the GitHub-based update system:
|
||||
-- update_channel (stable/latest), installed_commit_sha for hotfix tracking
|
||||
|
||||
-- 1. Add update channel setting (stable = releases only, latest = releases + hotfixes)
|
||||
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||
VALUES ('update_channel', 'stable', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
|
||||
-- 2. Add update badge in menu setting (1 = show when update available, 0 = hide)
|
||||
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||
VALUES ('update_badge_enabled', '1', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
|
||||
-- 3. Update application version to 1.1.3
|
||||
UPDATE settings
|
||||
SET setting_value = '1.1.3'
|
||||
WHERE setting_key = 'app_version';
|
||||
@@ -17,6 +17,7 @@ use App\Controllers\NotificationController;
|
||||
use App\Controllers\ErrorLogController;
|
||||
use App\Controllers\TwoFactorController;
|
||||
use App\Controllers\TagController;
|
||||
use App\Controllers\UpdateController;
|
||||
|
||||
$router = Application::$router;
|
||||
|
||||
@@ -62,6 +63,8 @@ $router->get('/api/search/suggest', [SearchController::class, 'suggest']);
|
||||
|
||||
// Domains
|
||||
$router->get('/domains', [DomainController::class, 'index']);
|
||||
$router->get('/domains/export', [DomainController::class, 'export']);
|
||||
$router->post('/domains/import', [DomainController::class, 'import']);
|
||||
$router->get('/domains/create', [DomainController::class, 'create']);
|
||||
$router->get('/domains/bulk-add', [DomainController::class, 'bulkAdd']);
|
||||
$router->post('/domains/bulk-add', [DomainController::class, 'bulkAdd']);
|
||||
@@ -86,6 +89,8 @@ $router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
||||
|
||||
// Notification Groups
|
||||
$router->get('/groups', [NotificationGroupController::class, 'index']);
|
||||
$router->get('/groups/export', [NotificationGroupController::class, 'export']);
|
||||
$router->post('/groups/import', [NotificationGroupController::class, 'import']);
|
||||
$router->get('/groups/create', [NotificationGroupController::class, 'create']);
|
||||
$router->post('/groups/store', [NotificationGroupController::class, 'store']);
|
||||
$router->get('/groups/{id}/edit', [NotificationGroupController::class, 'edit']);
|
||||
@@ -131,6 +136,14 @@ $router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
||||
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
||||
$router->post('/settings/toggle-isolation', [SettingsController::class, 'toggleIsolationMode']);
|
||||
|
||||
// Updates (Admin Only)
|
||||
$router->post('/api/updates/check', [UpdateController::class, 'check']);
|
||||
$router->post('/settings/updates/apply', [UpdateController::class, 'apply']);
|
||||
$router->post('/settings/updates/rollback', [UpdateController::class, 'rollback']);
|
||||
$router->post('/settings/updates/preferences', [UpdateController::class, 'savePreferences']);
|
||||
$router->post('/settings/updates/channel', [UpdateController::class, 'updateChannel']);
|
||||
$router->post('/settings/updates/badge', [UpdateController::class, 'updateBadgePreference']);
|
||||
|
||||
// Profile
|
||||
$router->get('/profile', [ProfileController::class, 'index']);
|
||||
$router->post('/profile/update', [ProfileController::class, 'update']);
|
||||
@@ -182,10 +195,14 @@ $router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolv
|
||||
|
||||
// Tag Management
|
||||
$router->get('/tags', [TagController::class, 'index']);
|
||||
$router->get('/tags/export', [TagController::class, 'export']);
|
||||
$router->post('/tags/import', [TagController::class, 'import']);
|
||||
$router->post('/tags/create', [TagController::class, 'create']);
|
||||
$router->post('/tags/update', [TagController::class, 'update']);
|
||||
$router->post('/tags/delete', [TagController::class, 'delete']);
|
||||
$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']);
|
||||
$router->post('/tags/transfer', [TagController::class, 'transfer']);
|
||||
$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']);
|
||||
$router->post('/tags/bulk-transfer', [TagController::class, 'bulkTransfer']);
|
||||
$router->get('/tags/{id}', [TagController::class, 'show']);
|
||||
$router->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']);
|
||||
$router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']);
|
||||
|
||||
Reference in New Issue
Block a user