1.1.0 (2025-10-09) - **User Notifications System** - In-app notification center with 7 notification types, filtering, pagination - **Advanced Session Management** - Database-backed sessions with geolocation (country, city, ISP) - **Remote Session Control** - Terminate any device instantly with immediate logout validation - **Enhanced Profile Page** - Sidebar navigation with 4 tabs, hash-based routing (#profile, #security, #sessions) - **MVC Architecture Refactoring** - 3 new Helpers (Layout, Domain, Session), ~265 lines cleaned from views - **Geolocation Tracking** - IP-based location detection using ip-api.com, country flags with flag-icons - **Device Detection** - Browser & device type parsing (Chrome/Firefox/Safari, Desktop/Mobile/Tablet) - **Auto-Detected Cron Paths** - Settings show actual installation paths (thanks @jadeops) - **Welcome Notifications** - Sent to new users on registration or fresh install - **Upgrade Notifications** - Admins notified on system updates with version & migration count - **Web-Based Installer** - Replaces CLI, auto-generates encryption key, one-time password display - **Web-Based Updater** - `/install/update` for running new migrations with smart detection - **User Registration** - Full signup flow with email verification, password reset, resend verification - **User Management** - CRUD for users with filtering, sorting, pagination (admin-only) - **Remember Me** - 30-day secure tokens linked to sessions, cascade deletion on logout - **Session Validator** - Middleware validates sessions on every request for instant remote logout - **Consistent UI/UX** - Unified filtering, sorting, pagination across Domains, Users, Notifications, TLD Registry - **Smart Migrations** - Consolidated schema for fresh installs, incremental for upgrades - **XSS Protection** - htmlspecialchars() applied across all user-facing data (thanks @jadeops)
353 lines
10 KiB
PHP
353 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use Core\Controller;
|
|
use Core\Auth;
|
|
use App\Models\User;
|
|
|
|
class UserController extends Controller
|
|
{
|
|
private User $userModel;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->userModel = new User();
|
|
|
|
// Ensure only admins can access user management
|
|
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
|
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
|
$this->redirect('/');
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all users
|
|
*/
|
|
public function index()
|
|
{
|
|
// Get filter parameters
|
|
$search = trim($_GET['search'] ?? '');
|
|
$roleFilter = $_GET['role'] ?? '';
|
|
$statusFilter = $_GET['status'] ?? '';
|
|
$sort = $_GET['sort'] ?? 'username';
|
|
$order = $_GET['order'] ?? 'asc';
|
|
$perPage = (int)($_GET['per_page'] ?? 25);
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
|
|
// Build filters array
|
|
$filters = [
|
|
'search' => $search,
|
|
'role' => $roleFilter,
|
|
'status' => $statusFilter
|
|
];
|
|
|
|
// Count total records
|
|
$totalRecords = $this->userModel->countFiltered($filters);
|
|
|
|
// Calculate pagination
|
|
$totalPages = ceil($totalRecords / $perPage);
|
|
$page = min($page, max(1, $totalPages)); // Ensure page is within bounds
|
|
$offset = ($page - 1) * $perPage;
|
|
$showingFrom = $totalRecords > 0 ? $offset + 1 : 0;
|
|
$showingTo = min($offset + $perPage, $totalRecords);
|
|
|
|
// Get filtered users
|
|
$users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset);
|
|
|
|
$this->view('users/index', [
|
|
'users' => $users,
|
|
'title' => 'User Management',
|
|
'filters' => [
|
|
'search' => $search,
|
|
'role' => $roleFilter,
|
|
'status' => $statusFilter,
|
|
'sort' => $sort,
|
|
'order' => strtolower($order)
|
|
],
|
|
'pagination' => [
|
|
'current_page' => $page,
|
|
'total_pages' => $totalPages,
|
|
'per_page' => $perPage,
|
|
'total' => $totalRecords,
|
|
'showing_from' => $showingFrom,
|
|
'showing_to' => $showingTo
|
|
]
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Show create user form
|
|
*/
|
|
public function create()
|
|
{
|
|
$this->view('users/create', [
|
|
'title' => 'Create User'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Store new user
|
|
*/
|
|
public function store()
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
$username = trim($_POST['username'] ?? '');
|
|
$email = trim($_POST['email'] ?? '');
|
|
$fullName = trim($_POST['full_name'] ?? '');
|
|
$password = $_POST['password'] ?? '';
|
|
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
|
$role = $_POST['role'] ?? 'user';
|
|
|
|
// Validation
|
|
if (empty($username) || empty($email) || empty($fullName) || empty($password)) {
|
|
$_SESSION['error'] = 'All fields are required';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$_SESSION['error'] = 'Invalid email address';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
|
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
if (strlen($password) < 8) {
|
|
$_SESSION['error'] = 'Password must be at least 8 characters';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
if ($password !== $passwordConfirm) {
|
|
$_SESSION['error'] = 'Passwords do not match';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
// Check if username exists
|
|
if ($this->userModel->findByUsername($username)) {
|
|
$_SESSION['error'] = 'Username already exists';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
// Check if email exists
|
|
if (!empty($this->userModel->where('email', $email))) {
|
|
$_SESSION['error'] = 'Email already exists';
|
|
$this->redirect('/users/create');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$userId = $this->userModel->createUser($username, $password, $email, $fullName);
|
|
|
|
// Update role if not default
|
|
if ($role !== 'user') {
|
|
$this->userModel->update($userId, ['role' => $role]);
|
|
}
|
|
|
|
// Mark as verified by default (admin created)
|
|
$this->userModel->update($userId, ['email_verified' => 1]);
|
|
|
|
// Create welcome notification
|
|
try {
|
|
$notificationService = new \App\Services\NotificationService();
|
|
$notificationService->notifyWelcome($userId, $username);
|
|
} catch (\Exception $e) {
|
|
// Don't fail user creation if notification fails
|
|
error_log("Failed to create welcome notification: " . $e->getMessage());
|
|
}
|
|
|
|
$_SESSION['success'] = 'User created successfully';
|
|
$this->redirect('/users');
|
|
|
|
} catch (\Exception $e) {
|
|
$_SESSION['error'] = 'Failed to create user: ' . $e->getMessage();
|
|
$this->redirect('/users/create');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show edit user form
|
|
*/
|
|
public function edit()
|
|
{
|
|
$userId = (int)($_GET['id'] ?? 0);
|
|
$user = $this->userModel->find($userId);
|
|
|
|
if (!$user) {
|
|
$_SESSION['error'] = 'User not found';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
$this->view('users/edit', [
|
|
'user' => $user,
|
|
'title' => 'Edit User'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update user
|
|
*/
|
|
public function update()
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
$userId = (int)($_POST['id'] ?? 0);
|
|
$user = $this->userModel->find($userId);
|
|
|
|
if (!$user) {
|
|
$_SESSION['error'] = 'User not found';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
$email = trim($_POST['email'] ?? '');
|
|
$fullName = trim($_POST['full_name'] ?? '');
|
|
$role = $_POST['role'] ?? 'user';
|
|
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
|
$password = $_POST['password'] ?? '';
|
|
|
|
// Validation
|
|
if (empty($email) || empty($fullName)) {
|
|
$_SESSION['error'] = 'Email and full name are required';
|
|
$this->redirect('/users/edit?id=' . $userId);
|
|
return;
|
|
}
|
|
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$_SESSION['error'] = 'Invalid email address';
|
|
$this->redirect('/users/edit?id=' . $userId);
|
|
return;
|
|
}
|
|
|
|
// Check if email is taken by another user
|
|
$existingUsers = $this->userModel->where('email', $email);
|
|
if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) {
|
|
$_SESSION['error'] = 'Email already in use by another user';
|
|
$this->redirect('/users/edit?id=' . $userId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$updateData = [
|
|
'email' => $email,
|
|
'full_name' => $fullName,
|
|
'role' => $role,
|
|
'is_active' => $isActive
|
|
];
|
|
|
|
$this->userModel->update($userId, $updateData);
|
|
|
|
// Update password if provided
|
|
if (!empty($password)) {
|
|
if (strlen($password) < 8) {
|
|
$_SESSION['error'] = 'Password must be at least 8 characters';
|
|
$this->redirect('/users/edit?id=' . $userId);
|
|
return;
|
|
}
|
|
$this->userModel->changePassword($userId, $password);
|
|
}
|
|
|
|
$_SESSION['success'] = 'User updated successfully';
|
|
$this->redirect('/users');
|
|
|
|
} catch (\Exception $e) {
|
|
$_SESSION['error'] = 'Failed to update user: ' . $e->getMessage();
|
|
$this->redirect('/users/edit?id=' . $userId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete user
|
|
*/
|
|
public function delete()
|
|
{
|
|
$userId = (int)($_GET['id'] ?? 0);
|
|
$user = $this->userModel->find($userId);
|
|
|
|
if (!$user) {
|
|
$_SESSION['error'] = 'User not found';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
// Prevent deleting yourself
|
|
if ($userId == Auth::id()) {
|
|
$_SESSION['error'] = 'You cannot delete your own account';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
// Prevent deleting the last admin
|
|
if ($user['role'] === 'admin') {
|
|
$allAdmins = $this->userModel->where('role', 'admin');
|
|
if (count($allAdmins) <= 1) {
|
|
$_SESSION['error'] = 'Cannot delete the last admin user';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->userModel->delete($userId);
|
|
$_SESSION['success'] = 'User deleted successfully';
|
|
$this->redirect('/users');
|
|
|
|
} catch (\Exception $e) {
|
|
$_SESSION['error'] = 'Failed to delete user: ' . $e->getMessage();
|
|
$this->redirect('/users');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle user active status
|
|
*/
|
|
public function toggleStatus()
|
|
{
|
|
$userId = (int)($_GET['id'] ?? 0);
|
|
$user = $this->userModel->find($userId);
|
|
|
|
if (!$user) {
|
|
$_SESSION['error'] = 'User not found';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
// Prevent deactivating yourself
|
|
if ($userId == Auth::id()) {
|
|
$_SESSION['error'] = 'You cannot deactivate your own account';
|
|
$this->redirect('/users');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$newStatus = $user['is_active'] ? 0 : 1;
|
|
$this->userModel->update($userId, ['is_active' => $newStatus]);
|
|
|
|
$_SESSION['success'] = 'User status updated successfully';
|
|
$this->redirect('/users');
|
|
|
|
} catch (\Exception $e) {
|
|
$_SESSION['error'] = 'Failed to update user status: ' . $e->getMessage();
|
|
$this->redirect('/users');
|
|
}
|
|
}
|
|
}
|
|
|