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:
Hosteroid
2025-10-20 17:04:13 +03:00
parent 52d20c2996
commit 6fbed15c7d
13 changed files with 825 additions and 61 deletions

View File

@@ -22,20 +22,35 @@ class DashboardController extends Controller
public function index()
{
$stats = $this->domainModel->getStatistics();
$recentDomains = $this->domainModel->getRecent(5); // Get 5 most recent domains
// Get current user and isolation mode
$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
$settingModel = new \App\Models\Setting();
$notificationDays = $settingModel->getNotificationDays();
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
// 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);
$recentLogs = $this->logModel->getRecent(10);
$groups = $this->groupModel->all();
// Check system status
$systemStatus = $this->checkSystemStatus();

View File

@@ -22,6 +22,11 @@ class DomainController extends Controller
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
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
$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
// Get expiring threshold from settings
$settingModel = new \App\Models\Setting();
$notificationDays = $settingModel->getNotificationDays();
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
@@ -46,12 +50,16 @@ class DomainController extends Controller
];
// 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 all unique tags for filter dropdown
$allTags = $this->domainModel->getAllTags();
// Get groups and tags based on isolation mode
if ($isolationMode === 'isolated') {
$groups = $this->groupModel->getAllWithChannelCount($userId);
$allTags = $this->domainModel->getAllTags($userId);
} else {
$groups = $this->groupModel->getAllWithChannelCount();
$allTags = $this->domainModel->getAllTags();
}
// Format domains for display
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
@@ -756,5 +764,106 @@ class DomainController extends Controller
$_SESSION['success'] = "Tags removed from $updated domain(s)";
$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');
}
}

View File

@@ -49,6 +49,7 @@ class InstallerController extends Controller
'015_create_error_logs_table.sql',
'016_add_tags_to_domains.sql',
'017_add_two_factor_authentication.sql',
'018_add_user_isolation.sql',
];
try {
@@ -268,6 +269,8 @@ class InstallerController extends Controller
'014_add_captcha_settings.sql',
'015_create_error_logs_table.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");

View File

@@ -19,10 +19,28 @@ class NotificationGroupController extends Controller
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', [
'groups' => $groups,
'users' => $users,
'title' => 'Notification Groups'
]);
}
@@ -69,10 +87,22 @@ class NotificationGroupController extends Controller
}
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,
'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";
$this->redirect("/groups/edit?id=$id");
@@ -492,5 +522,116 @@ class NotificationGroupController extends Controller
$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');
}
}

View File

@@ -27,12 +27,17 @@ class SearchController extends Controller
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
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25)));
// Search existing domains in database
$allResults = $this->searchDomains($query);
$allResults = $this->searchDomains($query, $isolationMode === 'isolated' ? $userId : null);
$totalResults = count($allResults);
// Calculate pagination
@@ -144,25 +149,47 @@ class SearchController extends Controller
/**
* Search domains in database
*/
private function searchDomains(string $query): array
private function searchDomains(string $query, ?int $userId = null): array
{
$db = \Core\Database::getConnection();
$sql = "SELECT d.*, ng.name as group_name
FROM domains d
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 ng.name LIKE ?
ORDER BY d.domain_name ASC
LIMIT 50";
OR ng.name LIKE ?)";
$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->execute([$searchTerm, $searchTerm, $searchTerm]);
$stmt->execute($params);
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
*/

View File

@@ -27,6 +27,7 @@ class SettingsController extends Controller
$emailSettings = $this->settingModel->getEmailSettings();
$captchaSettings = $this->settingModel->getCaptchaSettings();
$twoFactorSettings = $this->settingModel->getTwoFactorSettings();
$isolationSettings = $this->getIsolationSettings();
// Predefined notification day options
$notificationPresets = [
@@ -71,6 +72,7 @@ class SettingsController extends Controller
'emailSettings' => $emailSettings,
'captchaSettings' => $captchaSettings,
'twoFactorSettings' => $twoFactorSettings,
'isolationSettings' => $isolationSettings,
'notificationPresets' => $notificationPresets,
'checkIntervalPresets' => $checkIntervalPresets,
'title' => 'Settings'
@@ -491,5 +493,107 @@ class SettingsController extends Controller
$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()
];
}
}
}