Add user isolation mode and transfer features
Introduces user isolation mode, allowing domains, groups, and tags to be visible only to their owners when enabled. Adds user_id fields to domains and notification_groups, updates models and controllers for isolation-aware queries, and provides admin UI and endpoints for transferring domains and groups between users (single and bulk). Includes migration, settings UI, and routes for toggling isolation mode and handling data migration.
This commit is contained in:
@@ -22,20 +22,35 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$stats = $this->domainModel->getStatistics();
|
// Get current user and isolation mode
|
||||||
$recentDomains = $this->domainModel->getRecent(5); // Get 5 most recent domains
|
$userId = \Core\Auth::id();
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
|
// Get user-specific or global statistics
|
||||||
|
if ($isolationMode === 'isolated') {
|
||||||
|
$stats = $this->domainModel->getStatistics($userId);
|
||||||
|
$recentDomains = $this->domainModel->getRecent(5, $userId);
|
||||||
|
$groups = $this->groupModel->getAllWithChannelCount($userId);
|
||||||
|
} else {
|
||||||
|
$stats = $this->domainModel->getStatistics();
|
||||||
|
$recentDomains = $this->domainModel->getRecent(5);
|
||||||
|
$groups = $this->groupModel->getAllWithChannelCount();
|
||||||
|
}
|
||||||
|
|
||||||
// Get expiring threshold from settings
|
// Get expiring threshold from settings
|
||||||
$settingModel = new \App\Models\Setting();
|
|
||||||
$notificationDays = $settingModel->getNotificationDays();
|
$notificationDays = $settingModel->getNotificationDays();
|
||||||
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
|
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
|
||||||
|
|
||||||
// Get expiring domains limited to top 5
|
// Get expiring domains limited to top 5
|
||||||
$allExpiringDomains = $this->domainModel->getExpiringDomains($expiringThreshold);
|
if ($isolationMode === 'isolated') {
|
||||||
|
$allExpiringDomains = $this->domainModel->getExpiringDomains($expiringThreshold, $userId);
|
||||||
|
} else {
|
||||||
|
$allExpiringDomains = $this->domainModel->getExpiringDomains($expiringThreshold);
|
||||||
|
}
|
||||||
$expiringThisMonth = array_slice($allExpiringDomains, 0, 5);
|
$expiringThisMonth = array_slice($allExpiringDomains, 0, 5);
|
||||||
|
|
||||||
$recentLogs = $this->logModel->getRecent(10);
|
$recentLogs = $this->logModel->getRecent(10);
|
||||||
$groups = $this->groupModel->all();
|
|
||||||
|
|
||||||
// Check system status
|
// Check system status
|
||||||
$systemStatus = $this->checkSystemStatus();
|
$systemStatus = $this->checkSystemStatus();
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
// Get current user and isolation mode
|
||||||
|
$userId = \Core\Auth::id();
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
// Get filter parameters
|
// Get filter parameters
|
||||||
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
|
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
|
||||||
$status = $_GET['status'] ?? '';
|
$status = $_GET['status'] ?? '';
|
||||||
@@ -33,7 +38,6 @@ class DomainController extends Controller
|
|||||||
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100
|
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100
|
||||||
|
|
||||||
// Get expiring threshold from settings
|
// Get expiring threshold from settings
|
||||||
$settingModel = new \App\Models\Setting();
|
|
||||||
$notificationDays = $settingModel->getNotificationDays();
|
$notificationDays = $settingModel->getNotificationDays();
|
||||||
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
|
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
|
||||||
|
|
||||||
@@ -46,12 +50,16 @@ class DomainController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Get filtered and paginated domains using model
|
// Get filtered and paginated domains using model
|
||||||
$result = $this->domainModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $expiringThreshold);
|
$result = $this->domainModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $expiringThreshold, $isolationMode === 'isolated' ? $userId : null);
|
||||||
|
|
||||||
$groups = $this->groupModel->all();
|
// Get groups and tags based on isolation mode
|
||||||
|
if ($isolationMode === 'isolated') {
|
||||||
// Get all unique tags for filter dropdown
|
$groups = $this->groupModel->getAllWithChannelCount($userId);
|
||||||
$allTags = $this->domainModel->getAllTags();
|
$allTags = $this->domainModel->getAllTags($userId);
|
||||||
|
} else {
|
||||||
|
$groups = $this->groupModel->getAllWithChannelCount();
|
||||||
|
$allTags = $this->domainModel->getAllTags();
|
||||||
|
}
|
||||||
|
|
||||||
// Format domains for display
|
// Format domains for display
|
||||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
|
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
|
||||||
@@ -756,5 +764,106 @@ class DomainController extends Controller
|
|||||||
$_SESSION['success'] = "Tags removed from $updated domain(s)";
|
$_SESSION['success'] = "Tags removed from $updated domain(s)";
|
||||||
$this->redirect('/domains');
|
$this->redirect('/domains');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer domain to another user (Admin only)
|
||||||
|
*/
|
||||||
|
public function transfer()
|
||||||
|
{
|
||||||
|
\Core\Auth::requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$domainId = (int)($_POST['domain_id'] ?? 0);
|
||||||
|
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$domainId || !$targetUserId) {
|
||||||
|
$_SESSION['error'] = 'Invalid domain or user selected';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate domain exists
|
||||||
|
$domain = $this->domainModel->find($domainId);
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target user exists
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$targetUser = $userModel->find($targetUserId);
|
||||||
|
if (!$targetUser) {
|
||||||
|
$_SESSION['error'] = 'Target user not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transfer domain
|
||||||
|
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = "Domain '{$domain['domain_name']}' transferred to {$targetUser['username']}";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to transfer domain. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/domains');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk transfer domains to another user (Admin only)
|
||||||
|
*/
|
||||||
|
public function bulkTransfer()
|
||||||
|
{
|
||||||
|
\Core\Auth::requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$domainIds = $_POST['domain_ids'] ?? [];
|
||||||
|
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||||
|
|
||||||
|
if (empty($domainIds) || !$targetUserId) {
|
||||||
|
$_SESSION['error'] = 'No domains selected or invalid user';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target user exists
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$targetUser = $userModel->find($targetUserId);
|
||||||
|
if (!$targetUser) {
|
||||||
|
$_SESSION['error'] = 'Target user not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transferred = 0;
|
||||||
|
foreach ($domainIds as $domainId) {
|
||||||
|
$domainId = (int)$domainId;
|
||||||
|
if ($domainId > 0) {
|
||||||
|
try {
|
||||||
|
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
||||||
|
$transferred++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Continue with other domains
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}";
|
||||||
|
$this->redirect('/domains');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class InstallerController extends Controller
|
|||||||
'015_create_error_logs_table.sql',
|
'015_create_error_logs_table.sql',
|
||||||
'016_add_tags_to_domains.sql',
|
'016_add_tags_to_domains.sql',
|
||||||
'017_add_two_factor_authentication.sql',
|
'017_add_two_factor_authentication.sql',
|
||||||
|
'018_add_user_isolation.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -268,6 +269,8 @@ class InstallerController extends Controller
|
|||||||
'014_add_captcha_settings.sql',
|
'014_add_captcha_settings.sql',
|
||||||
'015_create_error_logs_table.sql',
|
'015_create_error_logs_table.sql',
|
||||||
'016_add_tags_to_domains.sql',
|
'016_add_tags_to_domains.sql',
|
||||||
|
'017_add_two_factor_authentication.sql',
|
||||||
|
'018_add_user_isolation.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
|
|||||||
@@ -19,10 +19,28 @@ class NotificationGroupController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$groups = $this->groupModel->getAllWithChannelCount();
|
// Get current user and isolation mode
|
||||||
|
$userId = \Core\Auth::id();
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
|
// Get groups based on isolation mode
|
||||||
|
if ($isolationMode === 'isolated') {
|
||||||
|
$groups = $this->groupModel->getAllWithChannelCount($userId);
|
||||||
|
} else {
|
||||||
|
$groups = $this->groupModel->getAllWithChannelCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users for transfer functionality (admin only)
|
||||||
|
$users = [];
|
||||||
|
if (\Core\Auth::user()['role'] === 'admin') {
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$users = $userModel->all();
|
||||||
|
}
|
||||||
|
|
||||||
$this->view('groups/index', [
|
$this->view('groups/index', [
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
|
'users' => $users,
|
||||||
'title' => 'Notification Groups'
|
'title' => 'Notification Groups'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -69,10 +87,22 @@ class NotificationGroupController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$id = $this->groupModel->create([
|
// Get current user and isolation mode
|
||||||
|
$userId = \Core\Auth::id();
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
|
$groupData = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'description' => $description
|
'description' => $description
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
// Assign to current user if in isolated mode
|
||||||
|
if ($isolationMode === 'isolated') {
|
||||||
|
$groupData['user_id'] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->groupModel->create($groupData);
|
||||||
|
|
||||||
$_SESSION['success'] = "Group '$name' created successfully";
|
$_SESSION['success'] = "Group '$name' created successfully";
|
||||||
$this->redirect("/groups/edit?id=$id");
|
$this->redirect("/groups/edit?id=$id");
|
||||||
@@ -492,5 +522,116 @@ class NotificationGroupController extends Controller
|
|||||||
|
|
||||||
$this->redirect('/groups');
|
$this->redirect('/groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer group to another user (Admin only)
|
||||||
|
*/
|
||||||
|
public function transfer()
|
||||||
|
{
|
||||||
|
\Core\Auth::requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/groups');
|
||||||
|
|
||||||
|
$groupId = (int)($_POST['group_id'] ?? 0);
|
||||||
|
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$groupId || !$targetUserId) {
|
||||||
|
$_SESSION['error'] = 'Invalid group or user selected';
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate group exists
|
||||||
|
$group = $this->groupModel->find($groupId);
|
||||||
|
if (!$group) {
|
||||||
|
$_SESSION['error'] = 'Group not found';
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target user exists
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$targetUser = $userModel->find($targetUserId);
|
||||||
|
if (!$targetUser) {
|
||||||
|
$_SESSION['error'] = 'Target user not found';
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transfer group
|
||||||
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
// Also transfer all domains in this group
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to transfer group. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk transfer groups to another user (Admin only)
|
||||||
|
*/
|
||||||
|
public function bulkTransfer()
|
||||||
|
{
|
||||||
|
\Core\Auth::requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/groups');
|
||||||
|
|
||||||
|
$groupIds = $_POST['group_ids'] ?? [];
|
||||||
|
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
||||||
|
|
||||||
|
if (empty($groupIds) || !$targetUserId) {
|
||||||
|
$_SESSION['error'] = 'No groups selected or invalid user';
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target user exists
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$targetUser = $userModel->find($targetUserId);
|
||||||
|
if (!$targetUser) {
|
||||||
|
$_SESSION['error'] = 'Target user not found';
|
||||||
|
$this->redirect('/groups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transferred = 0;
|
||||||
|
foreach ($groupIds as $groupId) {
|
||||||
|
$groupId = (int)$groupId;
|
||||||
|
if ($groupId > 0) {
|
||||||
|
try {
|
||||||
|
// Transfer group
|
||||||
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
// Also transfer all domains in this group
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$transferred++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Continue with other groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}";
|
||||||
|
$this->redirect('/groups');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ class SearchController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current user and isolation mode
|
||||||
|
$userId = \Core\Auth::id();
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
// Pagination parameters
|
// Pagination parameters
|
||||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25)));
|
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25)));
|
||||||
|
|
||||||
// Search existing domains in database
|
// Search existing domains in database
|
||||||
$allResults = $this->searchDomains($query);
|
$allResults = $this->searchDomains($query, $isolationMode === 'isolated' ? $userId : null);
|
||||||
$totalResults = count($allResults);
|
$totalResults = count($allResults);
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
@@ -144,25 +149,47 @@ class SearchController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Search domains in database
|
* Search domains in database
|
||||||
*/
|
*/
|
||||||
private function searchDomains(string $query): array
|
private function searchDomains(string $query, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
$db = \Core\Database::getConnection();
|
$db = \Core\Database::getConnection();
|
||||||
$sql = "SELECT d.*, ng.name as group_name
|
$sql = "SELECT d.*, ng.name as group_name
|
||||||
FROM domains d
|
FROM domains d
|
||||||
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
||||||
WHERE d.domain_name LIKE ?
|
WHERE (d.domain_name LIKE ?
|
||||||
OR d.registrar LIKE ?
|
OR d.registrar LIKE ?
|
||||||
OR ng.name LIKE ?
|
OR ng.name LIKE ?)";
|
||||||
ORDER BY d.domain_name ASC
|
|
||||||
LIMIT 50";
|
$params = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " AND d.user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY d.domain_name ASC LIMIT 50";
|
||||||
|
|
||||||
$searchTerm = '%' . $query . '%';
|
|
||||||
$stmt = $db->prepare($sql);
|
$stmt = $db->prepare($sql);
|
||||||
$stmt->execute([$searchTerm, $searchTerm, $searchTerm]);
|
$stmt->execute($params);
|
||||||
|
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
private function isAdmin(?int $userId): bool
|
||||||
|
{
|
||||||
|
if (!$userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Core\Database::getConnection();
|
||||||
|
$stmt = $db->prepare("SELECT role FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
return $user && $user['role'] === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if string looks like a domain name
|
* Check if string looks like a domain name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class SettingsController extends Controller
|
|||||||
$emailSettings = $this->settingModel->getEmailSettings();
|
$emailSettings = $this->settingModel->getEmailSettings();
|
||||||
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
$twoFactorSettings = $this->settingModel->getTwoFactorSettings();
|
$twoFactorSettings = $this->settingModel->getTwoFactorSettings();
|
||||||
|
$isolationSettings = $this->getIsolationSettings();
|
||||||
|
|
||||||
// Predefined notification day options
|
// Predefined notification day options
|
||||||
$notificationPresets = [
|
$notificationPresets = [
|
||||||
@@ -71,6 +72,7 @@ class SettingsController extends Controller
|
|||||||
'emailSettings' => $emailSettings,
|
'emailSettings' => $emailSettings,
|
||||||
'captchaSettings' => $captchaSettings,
|
'captchaSettings' => $captchaSettings,
|
||||||
'twoFactorSettings' => $twoFactorSettings,
|
'twoFactorSettings' => $twoFactorSettings,
|
||||||
|
'isolationSettings' => $isolationSettings,
|
||||||
'notificationPresets' => $notificationPresets,
|
'notificationPresets' => $notificationPresets,
|
||||||
'checkIntervalPresets' => $checkIntervalPresets,
|
'checkIntervalPresets' => $checkIntervalPresets,
|
||||||
'title' => 'Settings'
|
'title' => 'Settings'
|
||||||
@@ -491,5 +493,107 @@ class SettingsController extends Controller
|
|||||||
$this->redirect('/settings#security');
|
$this->redirect('/settings#security');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get isolation settings
|
||||||
|
*/
|
||||||
|
private function getIsolationSettings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_isolation_mode' => $this->settingModel->getValue('user_isolation_mode', 'shared')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle isolation mode
|
||||||
|
*/
|
||||||
|
public function toggleIsolationMode()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/settings#isolation');
|
||||||
|
|
||||||
|
$newMode = $_POST['user_isolation_mode'] ?? 'shared';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($newMode === 'isolated') {
|
||||||
|
// Check if we have any admin users
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$adminUser = $domainModel->getFirstAdminUser();
|
||||||
|
if (!$adminUser) {
|
||||||
|
$_SESSION['error'] = 'No admin users found. Please create an admin user first.';
|
||||||
|
$this->redirect('/settings#isolation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
$migrationResult = $this->migrateToIsolatedMode();
|
||||||
|
if (!$migrationResult['success']) {
|
||||||
|
$_SESSION['error'] = 'Migration failed: ' . $migrationResult['error'];
|
||||||
|
$this->redirect('/settings#isolation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains and {$migrationResult['groups_assigned']} groups assigned to admin.";
|
||||||
|
} else {
|
||||||
|
// Switching back to shared mode
|
||||||
|
$this->settingModel->setValue('user_isolation_mode', 'shared');
|
||||||
|
$_SESSION['success'] = 'Switched to shared mode. All users can now see all domains and groups.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/settings#isolation');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Error updating isolation mode: ' . $e->getMessage();
|
||||||
|
$this->redirect('/settings#isolation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate existing data to isolated mode
|
||||||
|
*/
|
||||||
|
private function migrateToIsolatedMode(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Get the first admin user
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$adminUser = $domainModel->getFirstAdminUser();
|
||||||
|
|
||||||
|
if (!$adminUser) {
|
||||||
|
throw new \Exception('No admin user found. Please create an admin user first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = $adminUser['id'];
|
||||||
|
|
||||||
|
// Assign all domains to admin
|
||||||
|
$domainStmt = $this->settingModel->db->prepare("UPDATE domains SET user_id = ? WHERE user_id IS NULL");
|
||||||
|
$domainStmt->execute([$adminId]);
|
||||||
|
$domainCount = $domainStmt->rowCount();
|
||||||
|
|
||||||
|
// Assign all groups to admin
|
||||||
|
$groupStmt = $this->settingModel->db->prepare("UPDATE notification_groups SET user_id = ? WHERE user_id IS NULL");
|
||||||
|
$groupStmt->execute([$adminId]);
|
||||||
|
$groupCount = $groupStmt->rowCount();
|
||||||
|
|
||||||
|
// Set isolation mode
|
||||||
|
$this->settingModel->setValue('user_isolation_mode', 'isolated');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'admin_id' => $adminId,
|
||||||
|
'domains_assigned' => $domainCount,
|
||||||
|
'groups_assigned' => $groupCount
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,28 @@ class Domain extends Model
|
|||||||
/**
|
/**
|
||||||
* Get all domains with their notification group
|
* Get all domains with their notification group
|
||||||
*/
|
*/
|
||||||
public function getAllWithGroups(): array
|
public function getAllWithGroups(?int $userId = null): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT d.*, ng.name as group_name
|
$sql = "SELECT d.*, ng.name as group_name
|
||||||
FROM domains d
|
FROM domains d
|
||||||
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id";
|
||||||
ORDER BY d.status DESC, d.expiration_date ASC";
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
$stmt = $this->db->query($sql);
|
$sql .= " WHERE d.user_id = ?";
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
} else {
|
||||||
|
$sql .= " ORDER BY d.status DESC, d.expiration_date ASC";
|
||||||
|
$stmt = $this->db->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get domains expiring within days
|
* Get domains expiring within days
|
||||||
*/
|
*/
|
||||||
public function getExpiringDomains(int $days): array
|
public function getExpiringDomains(int $days, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT d.*, ng.name as group_name
|
$sql = "SELECT d.*, ng.name as group_name
|
||||||
FROM domains d
|
FROM domains d
|
||||||
@@ -33,27 +40,43 @@ class Domain extends Model
|
|||||||
WHERE d.is_active = 1
|
WHERE d.is_active = 1
|
||||||
AND d.expiration_date IS NOT NULL
|
AND d.expiration_date IS NOT NULL
|
||||||
AND d.expiration_date <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
|
AND d.expiration_date <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
|
||||||
AND d.expiration_date >= CURDATE()
|
AND d.expiration_date >= CURDATE()";
|
||||||
ORDER BY d.expiration_date ASC";
|
|
||||||
|
$params = [$days];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " AND d.user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY d.expiration_date ASC";
|
||||||
|
|
||||||
$stmt = $this->db->prepare($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
$stmt->execute([$days]);
|
$stmt->execute($params);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get domains by status
|
* Get domains by status
|
||||||
*/
|
*/
|
||||||
public function getByStatus(string $status): array
|
public function getByStatus(string $status, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT d.*, ng.name as group_name
|
$sql = "SELECT d.*, ng.name as group_name
|
||||||
FROM domains d
|
FROM domains d
|
||||||
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
||||||
WHERE d.status = ?
|
WHERE d.status = ?";
|
||||||
ORDER BY d.expiration_date ASC";
|
|
||||||
|
$params = [$status];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " AND d.user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY d.expiration_date ASC";
|
||||||
|
|
||||||
$stmt = $this->db->prepare($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
$stmt->execute([$status]);
|
$stmt->execute($params);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,24 +123,32 @@ class Domain extends Model
|
|||||||
/**
|
/**
|
||||||
* Get recent domains
|
* Get recent domains
|
||||||
*/
|
*/
|
||||||
public function getRecent(int $limit = 5): array
|
public function getRecent(int $limit = 5, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT d.*, ng.name as group_name
|
$sql = "SELECT d.*, ng.name as group_name
|
||||||
FROM domains d
|
FROM domains d
|
||||||
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
|
||||||
WHERE d.is_active = 1
|
WHERE d.is_active = 1";
|
||||||
ORDER BY d.created_at DESC, d.id DESC
|
|
||||||
LIMIT ?";
|
$params = [];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " AND d.user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY d.created_at DESC, d.id DESC LIMIT ?";
|
||||||
|
$params[] = $limit;
|
||||||
|
|
||||||
$stmt = $this->db->prepare($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
$stmt->execute([$limit]);
|
$stmt->execute($params);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dashboard statistics
|
* Get dashboard statistics
|
||||||
*/
|
*/
|
||||||
public function getStatistics(): array
|
public function getStatistics(?int $userId = null): array
|
||||||
{
|
{
|
||||||
$stats = [
|
$stats = [
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
@@ -127,9 +158,19 @@ class Domain extends Model
|
|||||||
'inactive' => 0,
|
'inactive' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Build WHERE clause for user filtering
|
||||||
|
$whereClause = "WHERE is_active = 1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$whereClause .= " AND user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
// Get status counts for active domains only
|
// Get status counts for active domains only
|
||||||
$sql = "SELECT status, COUNT(*) as count FROM domains WHERE is_active = 1 GROUP BY status";
|
$sql = "SELECT status, COUNT(*) as count FROM domains $whereClause GROUP BY status";
|
||||||
$stmt = $this->db->query($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
$results = $stmt->fetchAll();
|
$results = $stmt->fetchAll();
|
||||||
|
|
||||||
$stats['total'] = array_sum(array_column($results, 'count'));
|
$stats['total'] = array_sum(array_column($results, 'count'));
|
||||||
@@ -139,7 +180,16 @@ class Domain extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get count of inactive domains (is_active = 0)
|
// Get count of inactive domains (is_active = 0)
|
||||||
$inactiveStmt = $this->db->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 0");
|
$inactiveWhereClause = "WHERE is_active = 0";
|
||||||
|
$inactiveParams = [];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$inactiveWhereClause .= " AND user_id = ?";
|
||||||
|
$inactiveParams[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inactiveStmt = $this->db->prepare("SELECT COUNT(*) as count FROM domains $inactiveWhereClause");
|
||||||
|
$inactiveStmt->execute($inactiveParams);
|
||||||
$inactiveResult = $inactiveStmt->fetch();
|
$inactiveResult = $inactiveStmt->fetch();
|
||||||
$stats['inactive'] = $inactiveResult['count'] ?? 0;
|
$stats['inactive'] = $inactiveResult['count'] ?? 0;
|
||||||
|
|
||||||
@@ -152,10 +202,10 @@ class Domain extends Model
|
|||||||
/**
|
/**
|
||||||
* Get filtered, sorted, and paginated domains
|
* Get filtered, sorted, and paginated domains
|
||||||
*/
|
*/
|
||||||
public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, int $expiringThreshold = 30): array
|
public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, int $expiringThreshold = 30, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
// Get all domains with groups
|
// Get all domains with groups
|
||||||
$domains = $this->getAllWithGroups();
|
$domains = $this->getAllWithGroups($userId);
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
if (!empty($filters['search'])) {
|
if (!empty($filters['search'])) {
|
||||||
@@ -242,9 +292,18 @@ class Domain extends Model
|
|||||||
/**
|
/**
|
||||||
* Get all unique tags from all domains
|
* Get all unique tags from all domains
|
||||||
*/
|
*/
|
||||||
public function getAllTags(): array
|
public function getAllTags(?int $userId = null): array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->query("SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''");
|
$sql = "SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " AND user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
$results = $stmt->fetchAll();
|
$results = $stmt->fetchAll();
|
||||||
|
|
||||||
$allTags = [];
|
$allTags = [];
|
||||||
@@ -260,5 +319,29 @@ class Domain extends Model
|
|||||||
sort($allTags);
|
sort($allTags);
|
||||||
return $allTags;
|
return $allTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
private function isAdmin(?int $userId): bool
|
||||||
|
{
|
||||||
|
if (!$userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare("SELECT role FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
return $user && $user['role'] === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get first admin user
|
||||||
|
*/
|
||||||
|
public function getFirstAdminUser(): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query("SELECT * FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1");
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,25 +11,31 @@ class NotificationGroup extends Model
|
|||||||
/**
|
/**
|
||||||
* Get all groups with channel count
|
* Get all groups with channel count
|
||||||
*/
|
*/
|
||||||
public function getAllWithChannelCount(): array
|
public function getAllWithChannelCount(?int $userId = null): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT ng.*,
|
$sql = "SELECT ng.*,
|
||||||
COUNT(DISTINCT nc.id) as channel_count,
|
COUNT(DISTINCT nc.id) as channel_count,
|
||||||
COUNT(DISTINCT d.id) as domain_count
|
COUNT(DISTINCT d.id) as domain_count
|
||||||
FROM notification_groups ng
|
FROM notification_groups ng
|
||||||
LEFT JOIN notification_channels nc ON ng.id = nc.notification_group_id
|
LEFT JOIN notification_channels nc ON ng.id = nc.notification_group_id
|
||||||
LEFT JOIN domains d ON ng.id = d.notification_group_id
|
LEFT JOIN domains d ON ng.id = d.notification_group_id";
|
||||||
GROUP BY ng.id
|
|
||||||
ORDER BY ng.name ASC";
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$sql .= " WHERE ng.user_id = ?";
|
||||||
$stmt = $this->db->query($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
} else {
|
||||||
|
$sql .= " GROUP BY ng.id ORDER BY ng.name ASC";
|
||||||
|
$stmt = $this->db->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get group with channels and domains
|
* Get group with channels and domains
|
||||||
*/
|
*/
|
||||||
public function getWithDetails(int $id): ?array
|
public function getWithDetails(int $id, ?int $userId = null): ?array
|
||||||
{
|
{
|
||||||
$group = $this->find($id);
|
$group = $this->find($id);
|
||||||
|
|
||||||
@@ -37,13 +43,22 @@ class NotificationGroup extends Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this group
|
||||||
|
if ($userId && !$this->isAdmin($userId) && $group['user_id'] != $userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get channels
|
// Get channels
|
||||||
$channelModel = new NotificationChannel();
|
$channelModel = new NotificationChannel();
|
||||||
$group['channels'] = $channelModel->getByGroupId($id);
|
$group['channels'] = $channelModel->getByGroupId($id);
|
||||||
|
|
||||||
// Get domains
|
// Get domains (filtered by user if needed)
|
||||||
$domainModel = new Domain();
|
$domainModel = new Domain();
|
||||||
$group['domains'] = $domainModel->where('notification_group_id', $id);
|
if ($userId && !$this->isAdmin($userId)) {
|
||||||
|
$group['domains'] = $domainModel->where('notification_group_id', $id, $userId);
|
||||||
|
} else {
|
||||||
|
$group['domains'] = $domainModel->where('notification_group_id', $id);
|
||||||
|
}
|
||||||
|
|
||||||
return $group;
|
return $group;
|
||||||
}
|
}
|
||||||
@@ -61,5 +76,29 @@ class NotificationGroup extends Model
|
|||||||
|
|
||||||
return $this->delete($id);
|
return $this->delete($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
private function isAdmin(?int $userId): bool
|
||||||
|
{
|
||||||
|
if (!$userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare("SELECT role FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
return $user && $user['role'] === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get first admin user
|
||||||
|
*/
|
||||||
|
public function getFirstAdminUser(): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query("SELECT * FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1");
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ ob_start();
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||||
|
|
||||||
|
<?php if (\Core\Auth::user()['role'] === 'admin'): ?>
|
||||||
|
<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">
|
<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>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Delete Selected
|
Delete Selected
|
||||||
@@ -106,6 +113,13 @@ ob_start();
|
|||||||
<a href="/groups/edit?id=<?= $group['id'] ?>" class="text-blue-600 hover:text-blue-800" title="Manage">
|
<a href="/groups/edit?id=<?= $group['id'] ?>" class="text-blue-600 hover:text-blue-800" title="Manage">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<?php if (\Core\Auth::user()['role'] === 'admin'): ?>
|
||||||
|
<button onclick="transferGroup(<?= $group['id'] ?>, '<?= htmlspecialchars($group['name']) ?>')"
|
||||||
|
class="text-green-600 hover:text-green-800"
|
||||||
|
title="Transfer Group">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
<a href="/groups/delete?id=<?= $group['id'] ?>"
|
<a href="/groups/delete?id=<?= $group['id'] ?>"
|
||||||
class="text-red-600 hover:text-red-800"
|
class="text-red-600 hover:text-red-800"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
@@ -252,6 +266,110 @@ function bulkDelete() {
|
|||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transfer single group
|
||||||
|
function transferGroup(groupId, groupName) {
|
||||||
|
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';
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk transfer groups
|
||||||
|
function bulkTransfer() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('input[name="group_ids[]"]:checked');
|
||||||
|
if (selectedCheckboxes.length === 0) {
|
||||||
|
alert('Please select groups 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';
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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}">`
|
||||||
|
).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">
|
||||||
|
<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">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||||
|
Transfer All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
<i class="fas fa-bell mr-2"></i>
|
<i class="fas fa-bell mr-2"></i>
|
||||||
Monitoring
|
Monitoring
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('isolation')" id="tab-isolation" 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">
|
||||||
|
<i class="fas fa-users mr-2"></i>
|
||||||
|
User Isolation
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('security')" id="tab-security" 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">
|
<button onclick="switchTab('security')" id="tab-security" 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">
|
||||||
<i class="fas fa-shield-alt mr-2"></i>
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
Security
|
Security
|
||||||
@@ -423,6 +427,77 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content: User Isolation Settings -->
|
||||||
|
<div id="content-isolation" class="tab-content hidden">
|
||||||
|
<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">User Isolation Settings</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Configure how users see domains, groups, and tags</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/settings/toggle-isolation" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Isolation Mode -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
User Data Visibility
|
||||||
|
</label>
|
||||||
|
<select name="user_isolation_mode" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="shared" <?= $isolationSettings['user_isolation_mode'] === 'shared' ? 'selected' : '' ?>>
|
||||||
|
🔓 Shared - All users see all domains, groups, and tags
|
||||||
|
</option>
|
||||||
|
<option value="isolated" <?= $isolationSettings['user_isolation_mode'] === 'isolated' ? 'selected' : '' ?>>
|
||||||
|
🔒 Isolated - Users only see their own domains, groups, and tags
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
<strong>Shared:</strong> Current behavior - everyone sees everything<br>
|
||||||
|
<strong>Isolated:</strong> Users only see what they created
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($isolationSettings['user_isolation_mode'] === 'shared'): ?>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-600 mt-1"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h4 class="text-sm font-medium text-yellow-800">Migration Notice</h4>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1">
|
||||||
|
When switching to isolated mode, all existing domains and groups will be assigned to the first admin user.
|
||||||
|
You can then transfer them to other users as needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($isolationSettings['user_isolation_mode'] === 'isolated'): ?>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle text-blue-600 mt-1"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h4 class="text-sm font-medium text-blue-800">Isolation Mode Active</h4>
|
||||||
|
<p class="text-sm text-blue-700 mt-1">
|
||||||
|
Users can only see their own domains, groups, and tags. Admins can transfer domains and groups between users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Update Isolation Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content: Security Settings -->
|
<!-- Tab Content: Security Settings -->
|
||||||
<div id="content-security" class="tab-content hidden">
|
<div id="content-security" class="tab-content hidden">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
@@ -795,7 +870,7 @@ function switchTab(tabName) {
|
|||||||
// Load tab from URL hash on page load
|
// Load tab from URL hash on page load
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = window.location.hash.substring(1); // Remove the #
|
const hash = window.location.hash.substring(1); // Remove the #
|
||||||
const validTabs = ['app', 'email', 'monitoring', 'security', 'system', 'maintenance'];
|
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance'];
|
||||||
|
|
||||||
if (hash && validTabs.includes(hash)) {
|
if (hash && validTabs.includes(hash)) {
|
||||||
switchTab(hash);
|
switchTab(hash);
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ CREATE TABLE IF NOT EXISTS notification_groups (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
user_id INT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_notification_groups_user_id (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Domains table
|
-- Domains table
|
||||||
@@ -30,15 +33,18 @@ CREATE TABLE IF NOT EXISTS domains (
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
tags TEXT NULL COMMENT 'Comma-separated tags for organization',
|
tags TEXT NULL COMMENT 'Comma-separated tags for organization',
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
user_id INT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE SET NULL,
|
FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
INDEX idx_notification_group_id (notification_group_id),
|
INDEX idx_notification_group_id (notification_group_id),
|
||||||
INDEX idx_domain_name (domain_name),
|
INDEX idx_domain_name (domain_name),
|
||||||
INDEX idx_expiration_date (expiration_date),
|
INDEX idx_expiration_date (expiration_date),
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
INDEX idx_is_active (is_active),
|
INDEX idx_is_active (is_active),
|
||||||
INDEX idx_tags (tags(255))
|
INDEX idx_tags (tags(255)),
|
||||||
|
INDEX idx_domains_user_id (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Notification channels table
|
-- Notification channels table
|
||||||
@@ -256,7 +262,10 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
|||||||
('captcha_provider', 'disabled', 'string', 'CAPTCHA provider (disabled, recaptcha_v2, recaptcha_v3, turnstile)'),
|
('captcha_provider', 'disabled', 'string', 'CAPTCHA provider (disabled, recaptcha_v2, recaptcha_v3, turnstile)'),
|
||||||
('captcha_site_key', '', 'string', 'CAPTCHA site/public key'),
|
('captcha_site_key', '', 'string', 'CAPTCHA site/public key'),
|
||||||
('captcha_secret_key', '', 'encrypted', 'CAPTCHA secret key (encrypted)'),
|
('captcha_secret_key', '', 'encrypted', 'CAPTCHA secret key (encrypted)'),
|
||||||
('recaptcha_v3_score_threshold', '0.5', 'string', 'reCAPTCHA v3 minimum score threshold (0.0 to 1.0)')
|
('recaptcha_v3_score_threshold', '0.5', 'string', 'reCAPTCHA v3 minimum score threshold (0.0 to 1.0)'),
|
||||||
|
|
||||||
|
-- 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)')
|
||||||
|
|
||||||
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||||
|
|
||||||
|
|||||||
36
database/migrations/018_add_user_isolation.sql
Normal file
36
database/migrations/018_add_user_isolation.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Add user isolation support
|
||||||
|
-- This migration adds user_id fields to domains and notification_groups tables
|
||||||
|
-- and adds the user_isolation_mode setting
|
||||||
|
|
||||||
|
-- Add user_id field to domains table
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN user_id INT NULL
|
||||||
|
AFTER is_active;
|
||||||
|
|
||||||
|
-- Add foreign key constraint for domains
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT fk_domains_user_id
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add index for user_id in domains
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD INDEX idx_domains_user_id (user_id);
|
||||||
|
|
||||||
|
-- Add user_id field to notification_groups table
|
||||||
|
ALTER TABLE notification_groups
|
||||||
|
ADD COLUMN user_id INT NULL
|
||||||
|
AFTER description;
|
||||||
|
|
||||||
|
-- Add foreign key constraint for notification_groups
|
||||||
|
ALTER TABLE notification_groups
|
||||||
|
ADD CONSTRAINT fk_notification_groups_user_id
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add index for user_id in notification_groups
|
||||||
|
ALTER TABLE notification_groups
|
||||||
|
ADD INDEX idx_notification_groups_user_id (user_id);
|
||||||
|
|
||||||
|
-- Add user isolation mode setting
|
||||||
|
INSERT INTO settings (setting_key, setting_value, description) VALUES
|
||||||
|
('user_isolation_mode', 'shared', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)')
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||||
@@ -70,6 +70,8 @@ $router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssig
|
|||||||
$router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']);
|
$router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']);
|
||||||
$router->post('/domains/bulk-add-tags', [DomainController::class, 'bulkAddTags']);
|
$router->post('/domains/bulk-add-tags', [DomainController::class, 'bulkAddTags']);
|
||||||
$router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']);
|
$router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']);
|
||||||
|
$router->post('/domains/transfer', [DomainController::class, 'transfer']);
|
||||||
|
$router->post('/domains/bulk-transfer', [DomainController::class, 'bulkTransfer']);
|
||||||
$router->post('/domains/store', [DomainController::class, 'store']);
|
$router->post('/domains/store', [DomainController::class, 'store']);
|
||||||
$router->get('/domains/{id}', [DomainController::class, 'show']);
|
$router->get('/domains/{id}', [DomainController::class, 'show']);
|
||||||
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']);
|
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']);
|
||||||
@@ -86,6 +88,8 @@ $router->get('/groups/edit', [NotificationGroupController::class, 'edit']);
|
|||||||
$router->post('/groups/update', [NotificationGroupController::class, 'update']);
|
$router->post('/groups/update', [NotificationGroupController::class, 'update']);
|
||||||
$router->get('/groups/delete', [NotificationGroupController::class, 'delete']);
|
$router->get('/groups/delete', [NotificationGroupController::class, 'delete']);
|
||||||
$router->post('/groups/bulk-delete', [NotificationGroupController::class, 'bulkDelete']);
|
$router->post('/groups/bulk-delete', [NotificationGroupController::class, 'bulkDelete']);
|
||||||
|
$router->post('/groups/transfer', [NotificationGroupController::class, 'transfer']);
|
||||||
|
$router->post('/groups/bulk-transfer', [NotificationGroupController::class, 'bulkTransfer']);
|
||||||
|
|
||||||
// Notification Channels
|
// Notification Channels
|
||||||
$router->post('/channels/add', [NotificationGroupController::class, 'addChannel']);
|
$router->post('/channels/add', [NotificationGroupController::class, 'addChannel']);
|
||||||
@@ -119,6 +123,7 @@ $router->post('/settings/update-two-factor', [SettingsController::class, 'update
|
|||||||
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
||||||
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
||||||
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
||||||
|
$router->post('/settings/toggle-isolation', [SettingsController::class, 'toggleIsolationMode']);
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
$router->get('/profile', [ProfileController::class, 'index']);
|
$router->get('/profile', [ProfileController::class, 'index']);
|
||||||
|
|||||||
Reference in New Issue
Block a user