Replace old team system with work teams (equipos de trabajo)

- New migration 003_teams.sql: teams, team_user, team_server tables
- New Team model with CRUD, members, and server assignment
- New TeamController with full team management
- New views: teams/index, teams/create, teams/show
- Server model: getUserRole and getAccessibleServerIds now include team-based access
- Sidebar: Teams link under Administration (admin+ only)
- Routes: old /team routes removed, new /teams routes added
- Removed old ServerController team methods and views
- Fixed sidebar empty accessible IDs returning all servers
- Fixed dashboard and API to show empty when no accessible servers
This commit is contained in:
2026-06-07 07:08:26 -04:00
parent 5f5de248c6
commit 5607a3e507
14 changed files with 941 additions and 418 deletions

View File

@@ -0,0 +1,47 @@
-- ============================================================================
-- ServerManager Teams
-- Team-based access control: teams group users and servers together
-- ============================================================================
CREATE TABLE IF NOT EXISTS `teams` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`description` TEXT DEFAULT NULL,
`created_by` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`),
KEY `idx_created_by` (`created_by`),
CONSTRAINT `fk_teams_created_by` FOREIGN KEY (`created_by`)
REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `team_user` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`team_id` INT UNSIGNED NOT NULL,
`user_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_team_user` (`team_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
CONSTRAINT `fk_team_user_team` FOREIGN KEY (`team_id`)
REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_team_user_user` FOREIGN KEY (`user_id`)
REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `team_server` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`team_id` INT UNSIGNED NOT NULL,
`server_id` INT UNSIGNED NOT NULL,
`role` ENUM('viewer', 'manager') NOT NULL DEFAULT 'viewer',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_team_server` (`team_id`, `server_id`),
KEY `idx_server_id` (`server_id`),
CONSTRAINT `fk_team_server_team` FOREIGN KEY (`team_id`)
REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_team_server_server` FOREIGN KEY (`server_id`)
REFERENCES `servers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -9,6 +9,7 @@ use ServerManager\Controllers\ServerController;
use ServerManager\Controllers\TerminalController;
use ServerManager\Controllers\AdminController;
use ServerManager\Controllers\ApiController;
use ServerManager\Controllers\TeamController;
use ServerManager\Middleware\AuthMiddleware;
use ServerManager\Middleware\CSRFMiddleware;
use ServerManager\Middleware\RateLimitMiddleware;
@@ -65,11 +66,17 @@ $router->get('/servers/:id/logs', [ServerController::class, 'logs'], [AuthMiddle
$router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [AuthMiddleware::class]);
$router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]);
$router->get('/team', [ServerController::class, 'team'], [AuthMiddleware::class]);
$router->get('/team/:id', [ServerController::class, 'teamServer'], [AuthMiddleware::class]);
$router->post('/team/:id/add-user', [ServerController::class, 'addTeamUser'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/team/:id/remove-user', [ServerController::class, 'removeTeamUser'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/team/:id/update-role', [ServerController::class, 'updateTeamUserRole'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
$router->get('/teams/create', [TeamController::class, 'create'], [AuthMiddleware::class, RoleMiddleware::class]);
$router->post('/teams/create', [TeamController::class, 'store'], [AuthMiddleware::class, CSRFMiddleware::class, RoleMiddleware::class]);
$router->get('/teams/:id', [TeamController::class, 'show'], [AuthMiddleware::class, RoleMiddleware::class]);
$router->post('/teams/:id/update', [TeamController::class, 'update'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/delete', [TeamController::class, 'delete'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/add-user', [TeamController::class, 'addUser'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/remove-user', [TeamController::class, 'removeUser'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/add-server', [TeamController::class, 'addServer'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/remove-server', [TeamController::class, 'removeServer'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/teams/:id/update-server-role', [TeamController::class, 'updateServerRole'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->get('/terminal/:id', [TerminalController::class, 'index'], [AuthMiddleware::class]);
$router->post('/terminal/:id/execute', [TerminalController::class, 'execute'], [AuthMiddleware::class, CSRFMiddleware::class]);

View File

@@ -77,9 +77,7 @@ class ApiController
if ($search) $filters['search'] = $search;
$accessibleIds = $serverModel->getAccessibleServerIds((int) $user['id']);
if (!empty($accessibleIds)) {
$filters['accessible_ids'] = $accessibleIds;
}
$filters['accessible_ids'] = !empty($accessibleIds) ? $accessibleIds : [-1];
$servers = $serverModel->getAll($page, $perPage, $filters);

View File

@@ -31,11 +31,9 @@ class DashboardController
$serverModel = new Server();
$userId = (int) Session::get('user_id');
$accessibleIds = $serverModel->getAccessibleServerIds($userId);
$servers = $serverModel->findAll(true);
$servers = array_filter($servers, function ($s) use ($accessibleIds) {
$servers = !empty($accessibleIds) ? array_values(array_filter($serverModel->findAll(true), function ($s) use ($accessibleIds) {
return in_array((int) $s['id'], $accessibleIds, true);
});
$servers = array_values($servers);
})) : [];
$this->view->display('dashboard.index', [
'title' => 'Dashboard - ServerManager',

View File

@@ -64,9 +64,7 @@ class ServerController
if ($group) $filters['group_name'] = $group;
$accessibleIds = $this->serverModel->getAccessibleServerIds($userId);
if (!empty($accessibleIds)) {
$filters['accessible_ids'] = $accessibleIds;
}
$filters['accessible_ids'] = !empty($accessibleIds) ? $accessibleIds : [-1];
$servers = $this->serverModel->getAll($page, 15, $filters);
$groups = $this->serverModel->getGroups();
@@ -1797,140 +1795,4 @@ class ServerController
]);
}
public function team(): void
{
$userId = (int) Session::get('user_id');
$servers = $this->serverModel->getOwnedServers($userId);
$result = [];
foreach ($servers as $server) {
$memberCount = count($this->serverModel->getTeam((int) $server['id']));
$result[] = [
'id' => $server['id'],
'name' => $server['name'],
'ip_address' => $server['ip_address'],
'member_count' => $memberCount,
'status' => $server['current_status'] ?? 'unknown',
];
}
$this->view->display('servers.team', [
'title' => 'Team Management - ServerManager',
'servers' => $result,
]);
}
public function teamServer(int $id): void
{
$userId = (int) Session::get('user_id');
$server = $this->serverModel->findById($id);
if (!$server || (int) $server['created_by'] !== $userId) {
$this->view->error(404);
}
$members = $this->serverModel->getTeam($id);
$userModel = new User();
$users = $userModel->getAll(1, 999);
$availableUsers = [];
$memberIds = array_map(fn($m) => $m['user_id'], $members);
foreach ($users['data'] as $u) {
if (!in_array((int) $u['id'], $memberIds, true)) {
$availableUsers[] = $u;
}
}
$this->view->display('servers.team_server', [
'title' => 'Team - ' . $server['name'] . ' - ServerManager',
'server' => $server,
'members' => $members,
'availableUsers' => $availableUsers,
]);
}
public function addTeamUser(int $id): void
{
$userId = (int) Session::get('user_id');
$server = $this->serverModel->findById($id);
if (!$server || (int) $server['created_by'] !== $userId) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$targetUserId = (int) ($_POST['user_id'] ?? 0);
$role = $_POST['role'] ?? 'viewer';
if ($targetUserId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid user']);
}
if (!in_array($role, ['viewer', 'manager'], true)) {
$this->view->json(['success' => false, 'message' => 'Invalid role']);
}
if ($this->serverModel->addUser($id, $targetUserId, $role)) {
$this->auditService->log('team_user_added', 'server', $id, [
'target_user_id' => $targetUserId,
'role' => $role,
]);
$this->view->json(['success' => true, 'message' => 'User added to server.']);
}
$this->view->json(['success' => false, 'message' => 'User is already assigned to this server.']);
}
public function removeTeamUser(int $id): void
{
$userId = (int) Session::get('user_id');
$server = $this->serverModel->findById($id);
if (!$server || (int) $server['created_by'] !== $userId) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$targetUserId = (int) ($_POST['user_id'] ?? 0);
if ($targetUserId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid user']);
}
$this->serverModel->removeUser($id, $targetUserId);
$this->auditService->log('team_user_removed', 'server', $id, [
'target_user_id' => $targetUserId,
]);
$this->view->json(['success' => true, 'message' => 'User removed from server.']);
}
public function updateTeamUserRole(int $id): void
{
$userId = (int) Session::get('user_id');
$server = $this->serverModel->findById($id);
if (!$server || (int) $server['created_by'] !== $userId) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$targetUserId = (int) ($_POST['user_id'] ?? 0);
$role = $_POST['role'] ?? 'viewer';
if ($targetUserId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid user']);
}
if (!in_array($role, ['viewer', 'manager'], true)) {
$this->view->json(['success' => false, 'message' => 'Invalid role']);
}
$this->serverModel->updateUserRole($id, $targetUserId, $role);
$this->auditService->log('team_user_role_updated', 'server', $id, [
'target_user_id' => $targetUserId,
'role' => $role,
]);
$this->view->json(['success' => true, 'message' => 'User role updated.']);
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Core\Session;
use ServerManager\Core\Validator;
use ServerManager\Models\Team;
use ServerManager\Models\Server;
use ServerManager\Models\User;
use ServerManager\Services\AuditService;
class TeamController
{
private \ServerManager\Core\View $view;
private Team $teamModel;
private Server $serverModel;
private AuditService $auditService;
public function __construct()
{
$this->view = App::getInstance()->getView();
$this->teamModel = new Team();
$this->serverModel = new Server();
$this->auditService = new AuditService();
}
public function index(): void
{
$teams = $this->teamModel->findAll();
$this->view->display('teams.index', [
'title' => 'Teams - ServerManager',
'teams' => $teams,
]);
}
public function create(): void
{
$this->view->display('teams.create', [
'title' => 'Create Team - ServerManager',
]);
}
public function store(): void
{
$validator = new Validator();
$rules = [
'name' => 'required|min:2|max:100',
'description' => 'max:500',
];
if (!$validator->validate($_POST, $rules)) {
Session::setFlash('error', $validator->getFirstError());
$this->view->redirect('/teams/create');
}
$id = $this->teamModel->create([
'name' => $_POST['name'],
'description' => $_POST['description'] ?? '',
]);
$this->auditService->log('team_created', 'team', $id, [
'name' => $_POST['name'],
]);
Session::setFlash('success', 'Team created successfully.');
$this->view->redirect('/teams');
}
public function show(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->error(404);
}
$members = $this->teamModel->getMembers($id);
$servers = $this->teamModel->getServers($id);
$userModel = new User();
$allUsers = $userModel->getAll(1, 999);
$memberIds = array_map(fn($m) => $m['user_id'], $members);
$availableUsers = array_filter($allUsers['data'], fn($u) => !in_array((int) $u['id'], $memberIds, true));
$userId = (int) Session::get('user_id');
$ownedServers = $this->serverModel->getOwnedServers($userId);
$serverIds = array_map(fn($s) => (int) $s['id'], $servers);
$availableServers = array_filter($ownedServers, fn($s) => !in_array((int) $s['id'], $serverIds, true));
$this->view->display('teams.show', [
'title' => $team['name'] . ' - Teams - ServerManager',
'team' => $team,
'members' => $members,
'servers' => $servers,
'availableUsers' => $availableUsers,
'availableServers' => $availableServers,
]);
}
public function update(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$validator = new Validator();
$rules = [
'name' => 'required|min:2|max:100',
'description' => 'max:500',
];
if (!$validator->validate($_POST, $rules)) {
$this->view->json(['success' => false, 'message' => $validator->getFirstError()]);
}
$this->teamModel->update($id, [
'name' => $_POST['name'],
'description' => $_POST['description'] ?? '',
]);
$this->auditService->log('team_updated', 'team', $id, [
'name' => $_POST['name'],
]);
$this->view->json(['success' => true, 'message' => 'Team updated.']);
}
public function delete(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$this->teamModel->delete($id);
$this->auditService->log('team_deleted', 'team', $id, [
'name' => $team['name'],
]);
Session::setFlash('success', 'Team deleted.');
$this->view->redirect('/teams');
}
public function addUser(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$userId = (int) ($_POST['user_id'] ?? 0);
if ($userId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid user']);
}
if ($this->teamModel->addMember($id, $userId)) {
$this->auditService->log('team_user_added', 'team', $id, [
'user_id' => $userId,
]);
$this->view->json(['success' => true, 'message' => 'User added to team.']);
}
$this->view->json(['success' => false, 'message' => 'User is already in this team.']);
}
public function removeUser(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$userId = (int) ($_POST['user_id'] ?? 0);
if ($userId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid user']);
}
$this->teamModel->removeMember($id, $userId);
$this->auditService->log('team_user_removed', 'team', $id, [
'user_id' => $userId,
]);
$this->view->json(['success' => true, 'message' => 'User removed from team.']);
}
public function addServer(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$serverId = (int) ($_POST['server_id'] ?? 0);
$role = $_POST['role'] ?? 'viewer';
if ($serverId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid server']);
}
if (!in_array($role, ['viewer', 'manager'], true)) {
$this->view->json(['success' => false, 'message' => 'Invalid role']);
}
$userId = (int) Session::get('user_id');
$server = $this->serverModel->findById($serverId);
if (!$server || (int) $server['created_by'] !== $userId) {
$this->view->json(['success' => false, 'message' => 'Server not found or not owned by you'], 404);
}
if ($this->teamModel->addServer($id, $serverId, $role)) {
$this->auditService->log('team_server_added', 'team', $id, [
'server_id' => $serverId,
'role' => $role,
]);
$this->view->json(['success' => true, 'message' => 'Server assigned to team.']);
}
$this->view->json(['success' => false, 'message' => 'Server is already assigned to this team.']);
}
public function removeServer(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$serverId = (int) ($_POST['server_id'] ?? 0);
if ($serverId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid server']);
}
$this->teamModel->removeServer($id, $serverId);
$this->auditService->log('team_server_removed', 'team', $id, [
'server_id' => $serverId,
]);
$this->view->json(['success' => true, 'message' => 'Server removed from team.']);
}
public function updateServerRole(int $id): void
{
$team = $this->teamModel->findById($id);
if (!$team) {
$this->view->json(['success' => false, 'message' => 'Team not found'], 404);
}
$serverId = (int) ($_POST['server_id'] ?? 0);
$role = $_POST['role'] ?? 'viewer';
if ($serverId <= 0) {
$this->view->json(['success' => false, 'message' => 'Invalid server']);
}
if (!in_array($role, ['viewer', 'manager'], true)) {
$this->view->json(['success' => false, 'message' => 'Invalid role']);
}
$this->teamModel->updateServerRole($id, $serverId, $role);
$this->auditService->log('team_server_role_updated', 'team', $id, [
'server_id' => $serverId,
'role' => $role,
]);
$this->view->json(['success' => true, 'message' => 'Server role updated.']);
}
}

View File

@@ -211,12 +211,31 @@ class Server
return 'owner';
}
$assignment = $this->db->fetch(
$direct = $this->db->fetch(
'SELECT role FROM server_user WHERE server_id = ? AND user_id = ?',
[$serverId, $userId]
);
return $assignment ? $assignment['role'] : null;
$teamRole = $this->db->fetch(
'SELECT ts.role FROM team_user tu
JOIN team_server ts ON tu.team_id = ts.team_id
WHERE tu.user_id = ? AND ts.server_id = ?
ORDER BY FIELD(ts.role, \'manager\', \'viewer\')
LIMIT 1',
[$userId, $serverId]
);
$directRole = $direct ? $direct['role'] : null;
$teamRoleValue = $teamRole ? $teamRole['role'] : null;
if ($directRole === 'manager' || $teamRoleValue === 'manager') {
return 'manager';
}
if ($directRole === 'viewer' || $teamRoleValue === 'viewer') {
return 'viewer';
}
return null;
}
public function getAccessibleServerIds(int $userId): array
@@ -231,6 +250,13 @@ class Server
[$userId]
);
$teamServerIds = $this->db->fetchAll(
'SELECT DISTINCT ts.server_id FROM team_user tu
JOIN team_server ts ON tu.team_id = ts.team_id
WHERE tu.user_id = ?',
[$userId]
);
$ids = [];
foreach ($owned as $row) {
$ids[] = (int) $row['id'];
@@ -238,6 +264,9 @@ class Server
foreach ($assigned as $row) {
$ids[] = (int) $row['server_id'];
}
foreach ($teamServerIds as $row) {
$ids[] = (int) $row['server_id'];
}
return array_unique($ids);
}

155
src/Models/Team.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
use ServerManager\Core\Session;
class Team
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function findAll(): array
{
return $this->db->fetchAll(
'SELECT t.*,
(SELECT COUNT(*) FROM team_user WHERE team_id = t.id) AS member_count,
(SELECT COUNT(*) FROM team_server WHERE team_id = t.id) AS server_count
FROM teams t
ORDER BY t.name ASC'
);
}
public function findById(int $id): ?array
{
$team = $this->db->fetch(
'SELECT t.*,
(SELECT COUNT(*) FROM team_user WHERE team_id = t.id) AS member_count,
(SELECT COUNT(*) FROM team_server WHERE team_id = t.id) AS server_count
FROM teams t WHERE t.id = ?',
[$id]
);
return $team ?: null;
}
public function create(array $data): int
{
$data['created_by'] = (int) Session::get('user_id');
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->db->insert('teams', $data);
}
public function update(int $id, array $data): bool
{
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->db->update('teams', $data, 'id = ?', [$id]) > 0;
}
public function delete(int $id): bool
{
return $this->db->delete('teams', 'id = ?', [$id]) > 0;
}
public function getMembers(int $teamId): array
{
return $this->db->fetchAll(
'SELECT tu.*, u.username, u.email
FROM team_user tu
JOIN users u ON tu.user_id = u.id
WHERE tu.team_id = ?
ORDER BY u.username ASC',
[$teamId]
);
}
public function addMember(int $teamId, int $userId): bool
{
$existing = $this->db->fetch(
'SELECT id FROM team_user WHERE team_id = ? AND user_id = ?',
[$teamId, $userId]
);
if ($existing) {
return false;
}
$this->db->insert('team_user', [
'team_id' => $teamId,
'user_id' => $userId,
]);
return true;
}
public function removeMember(int $teamId, int $userId): bool
{
return $this->db->delete(
'team_user',
'team_id = ? AND user_id = ?',
[$teamId, $userId]
) > 0;
}
public function getServers(int $teamId): array
{
return $this->db->fetchAll(
'SELECT ts.*, s.name AS server_name, s.ip_address
FROM team_server ts
JOIN servers s ON ts.server_id = s.id
WHERE ts.team_id = ?
ORDER BY s.name ASC',
[$teamId]
);
}
public function addServer(int $teamId, int $serverId, string $role): bool
{
if (!in_array($role, ['viewer', 'manager'], true)) {
return false;
}
$existing = $this->db->fetch(
'SELECT id FROM team_server WHERE team_id = ? AND server_id = ?',
[$teamId, $serverId]
);
if ($existing) {
return false;
}
$this->db->insert('team_server', [
'team_id' => $teamId,
'server_id' => $serverId,
'role' => $role,
]);
return true;
}
public function removeServer(int $teamId, int $serverId): bool
{
return $this->db->delete(
'team_server',
'team_id = ? AND server_id = ?',
[$teamId, $serverId]
) > 0;
}
public function updateServerRole(int $teamId, int $serverId, string $role): bool
{
if (!in_array($role, ['viewer', 'manager'], true)) {
return false;
}
return $this->db->update(
'team_server',
['role' => $role],
'team_id = ? AND server_id = ?',
[$teamId, $serverId]
) > 0;
}
}

View File

@@ -39,15 +39,15 @@
<div class="nav-submenu-loading"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</li>
<li class="nav-item">
<a href="/team" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/team') ? 'active' : '' ?>">
<i class="fas fa-users-cog"></i>
<span>Team</span>
</a>
</li>
<li class="nav-divider"></li>
<li class="nav-section">Administration</li>
<?php if (in_array($_SESSION['user_role'] ?? '', ['super_admin', 'admin'])): ?>
<li class="nav-item">
<a href="/teams" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/teams') ? 'active' : '' ?>">
<i class="fas fa-users-cog"></i>
<span>Teams</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/users') ? 'active' : '' ?>">
<i class="fas fa-users"></i>

View File

@@ -1,60 +0,0 @@
<?php $servers = $servers ?? []; ?>
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Team Management</h1>
<p class="page-subtitle">Manage who has access to your servers</p>
</div>
</div>
<div class="card">
<div class="card-body">
<?php if (empty($servers)): ?>
<div class="empty-state">
<i class="fas fa-users-cog"></i>
<p>You don't own any servers yet.</p>
<p class="text-muted">Servers you create will appear here so you can manage their team access.</p>
<a href="/servers/create" class="btn btn-primary">Add Server</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Server</th>
<th>IP Address</th>
<th>Status</th>
<th>Team Members</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($servers as $server): ?>
<tr>
<td>
<a href="/servers/<?= $server['id'] ?>">
<?= htmlspecialchars($server['name']) ?>
</a>
</td>
<td><code><?= htmlspecialchars($server['ip_address']) ?></code></td>
<td>
<span class="status-badge status-<?= $server['status'] ?? 'unknown' ?>">
<?= ucfirst($server['status'] ?? 'unknown') ?>
</span>
</td>
<td>
<span class="badge badge-info"><?= $server['member_count'] ?> members</span>
</td>
<td>
<a href="/team/<?= $server['id'] ?>" class="btn btn-sm">
<i class="fas fa-users"></i> Manage Team
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -1,199 +0,0 @@
<?php
$server = $server ?? [];
$members = $members ?? [];
$availableUsers = $availableUsers ?? [];
?>
<div class="page-header">
<div class="page-header-left">
<div class="back-link">
<a href="/team"><i class="fas fa-arrow-left"></i> Back to Team</a>
</div>
<h1 class="page-title"><?= htmlspecialchars($server['name'] ?? '') ?></h1>
<p class="page-subtitle">Manage team access for this server</p>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-users"></i> Team Members</h2>
</div>
<div class="card-body">
<?php if (empty($members)): ?>
<div class="empty-state">
<i class="fas fa-users"></i>
<p>No team members yet.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="teamMembersBody">
<?php foreach ($members as $member): ?>
<tr data-user-id="<?= $member['user_id'] ?>">
<td>
<strong><?= htmlspecialchars($member['username']) ?></strong>
<br><small class="text-muted"><?= htmlspecialchars($member['email']) ?></small>
</td>
<td>
<?php if ($member['role'] === 'owner'): ?>
<span class="badge badge-danger">Owner</span>
<?php else: ?>
<select class="form-select form-select-sm role-select"
onchange="updateRole(<?= $member['user_id'] ?>, this.value)"
style="width:auto">
<option value="viewer" <?= $member['role'] === 'viewer' ? 'selected' : '' ?>>Viewer</option>
<option value="manager" <?= $member['role'] === 'manager' ? 'selected' : '' ?>>Manager</option>
</select>
<?php endif; ?>
</td>
<td>
<?php if ($member['role'] !== 'owner'): ?>
<button class="btn btn-xs btn-danger"
onclick="removeUser(<?= $member['user_id'] ?>, '<?= htmlspecialchars(addslashes($member['username'])) ?>')">
<i class="fas fa-trash"></i>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-user-plus"></i> Add User</h2>
</div>
<div class="card-body">
<?php if (empty($availableUsers)): ?>
<div class="empty-state">
<i class="fas fa-user-plus"></i>
<p>All users already have access to this server.</p>
</div>
<?php else: ?>
<form id="addUserForm" class="form" onsubmit="addUser(event)">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
<div class="form-group">
<label for="userId">User</label>
<select id="userId" name="user_id" class="form-select" required>
<option value="">Select a user...</option>
<?php foreach ($availableUsers as $user): ?>
<option value="<?= $user['id'] ?>">
<?= htmlspecialchars($user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" class="form-select" required>
<option value="viewer">Viewer — can view server details and use terminal</option>
<option value="manager">Manager — full control except team management</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus"></i> Add User
</button>
</form>
<?php endif; ?>
</div>
</div>
</div>
<form id="csrfForm" style="display:none">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
</form>
<script>
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || '';
}
function addUser(e) {
e.preventDefault();
const form = document.getElementById('addUserForm');
const data = new FormData(form);
fetch('/team/<?= $server['id'] ?>/add-user', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrfToken(),
},
body: data,
})
.then(r => r.json())
.then(res => {
if (res.success) {
showToast('success', res.message);
location.reload();
} else {
showToast('error', res.message);
}
})
.catch(err => showToast('error', 'Request failed'));
}
function removeUser(userId, username) {
if (!confirm('Remove "' + username + '" from this server?')) return;
const form = new FormData();
form.append('user_id', userId);
form.append('_csrf_token', getCsrfToken());
fetch('/team/<?= $server['id'] ?>/remove-user', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrfToken(),
},
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) {
showToast('success', res.message);
location.reload();
} else {
showToast('error', res.message);
}
})
.catch(err => showToast('error', 'Request failed'));
}
function updateRole(userId, role) {
const form = new FormData();
form.append('user_id', userId);
form.append('role', role);
form.append('_csrf_token', getCsrfToken());
fetch('/team/<?= $server['id'] ?>/update-role', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrfToken(),
},
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) {
showToast('success', 'Role updated to ' + role);
} else {
showToast('error', res.message);
}
})
.catch(err => showToast('error', 'Request failed'));
}
</script>

36
views/teams/create.php Normal file
View File

@@ -0,0 +1,36 @@
<div class="page-header">
<div class="page-header-left">
<div class="back-link">
<a href="/teams"><i class="fas fa-arrow-left"></i> Back to Teams</a>
</div>
<h1 class="page-title">Create Team</h1>
<p class="page-subtitle">Create a work team to manage server access</p>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="POST" action="/teams/create" class="form" style="max-width:600px">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
<div class="form-group">
<label for="name">Team Name *</label>
<input type="text" id="name" name="name" class="form-input"
placeholder="e.g., DevOps, Developers, Support" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-input" rows="3"
placeholder="Optional description of the team"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Team
</button>
<a href="/teams" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>

78
views/teams/index.php Normal file
View File

@@ -0,0 +1,78 @@
<?php $teams = $teams ?? []; ?>
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Teams</h1>
<p class="page-subtitle">Create and manage work teams with server access</p>
</div>
<div class="page-header-right">
<a href="/teams/create" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Team
</a>
</div>
</div>
<div class="card">
<div class="card-body">
<?php if (empty($teams)): ?>
<div class="empty-state">
<i class="fas fa-users-cog"></i>
<p>No teams created yet.</p>
<a href="/teams/create" class="btn btn-primary">Create Your First Team</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Servers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($teams as $team): ?>
<tr>
<td>
<a href="/teams/<?= $team['id'] ?>" class="fw-semibold">
<?= htmlspecialchars($team['name']) ?>
</a>
</td>
<td class="text-muted"><?= htmlspecialchars(mb_substr($team['description'] ?? '', 0, 80)) ?></td>
<td><span class="badge badge-info"><?= (int) ($team['member_count'] ?? 0) ?></span></td>
<td><span class="badge badge-primary"><?= (int) ($team['server_count'] ?? 0) ?></span></td>
<td>
<div class="btn-group">
<a href="/teams/<?= $team['id'] ?>" class="btn btn-xs" title="Manage">
<i class="fas fa-cog"></i>
</a>
<button class="btn btn-xs btn-danger" title="Delete"
onclick="deleteTeam(<?= $team['id'] ?>, '<?= htmlspecialchars(addslashes($team['name'])) ?>')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<form id="deleteForm" method="POST" style="display:none">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
</form>
<script>
function deleteTeam(id, name) {
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
const form = document.getElementById('deleteForm');
form.action = '/teams/' + id + '/delete';
form.submit();
}
}
</script>

289
views/teams/show.php Normal file
View File

@@ -0,0 +1,289 @@
<?php
$team = $team ?? [];
$members = $members ?? [];
$servers = $servers ?? [];
$availableUsers = $availableUsers ?? [];
$availableServers = $availableServers ?? [];
?>
<div class="page-header">
<div class="page-header-left">
<div class="back-link">
<a href="/teams"><i class="fas fa-arrow-left"></i> Back to Teams</a>
</div>
<h1 class="page-title"><?= htmlspecialchars($team['name'] ?? '') ?></h1>
<p class="page-subtitle"><?= htmlspecialchars($team['description'] ?? '') ?></p>
</div>
<div class="page-header-right">
<button class="btn btn-secondary" onclick="editTeam()">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-danger" onclick="deleteTeam()">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-users"></i> Members</h2>
<span class="badge badge-info"><?= count($members) ?></span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>User</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($members as $member): ?>
<tr>
<td>
<strong><?= htmlspecialchars($member['username']) ?></strong>
<br><small class="text-muted"><?= htmlspecialchars($member['email']) ?></small>
</td>
<td>
<button class="btn btn-xs btn-danger"
onclick="removeMember(<?= $member['user_id'] ?>, '<?= htmlspecialchars(addslashes($member['username'])) ?>')">
<i class="fas fa-user-minus"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (!empty($availableUsers)): ?>
<form class="form-inline" style="margin-top:1rem;display:flex;gap:0.5rem;align-items:end" onsubmit="addMember(event)">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
<div class="form-group" style="flex:1;margin-bottom:0">
<select id="addUserId" class="form-select" required>
<option value="">Add user...</option>
<?php foreach ($availableUsers as $user): ?>
<option value="<?= $user['id'] ?>">
<?= htmlspecialchars($user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> Add
</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-server"></i> Servers</h2>
<span class="badge badge-primary"><?= count($servers) ?></span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Server</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($servers as $s): ?>
<tr>
<td>
<a href="/servers/<?= $s['server_id'] ?>">
<?= htmlspecialchars($s['server_name']) ?>
</a>
</td>
<td>
<select class="form-select form-select-sm role-select"
onchange="updateServerRole(<?= $s['server_id'] ?>, this.value)"
style="width:auto">
<option value="viewer" <?= $s['role'] === 'viewer' ? 'selected' : '' ?>>Viewer</option>
<option value="manager" <?= $s['role'] === 'manager' ? 'selected' : '' ?>>Manager</option>
</select>
</td>
<td>
<button class="btn btn-xs btn-danger"
onclick="removeServer(<?= $s['server_id'] ?>, '<?= htmlspecialchars(addslashes($s['server_name'])) ?>')">
<i class="fas fa-unlink"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (!empty($availableServers)): ?>
<form class="form-inline" style="margin-top:1rem;display:flex;gap:0.5rem;align-items:end" onsubmit="addServer(event)">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
<div class="form-group" style="flex:1;margin-bottom:0">
<select id="addServerId" class="form-select" required>
<option value="">Assign server...</option>
<?php foreach ($availableServers as $s): ?>
<option value="<?= $s['id'] ?>">
<?= htmlspecialchars($s['name']) ?> (<?= htmlspecialchars($s['ip_address']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin-bottom:0">
<select id="addServerRole" class="form-select" required>
<option value="viewer">Viewer</option>
<option value="manager">Manager</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> Assign
</button>
</form>
<?php endif; ?>
</div>
</div>
</div>
<form id="postForm" method="POST" style="display:none">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
</form>
<script>
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || '';
}
function editTeam() {
const name = prompt('Team name:', '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>');
if (!name) return;
const desc = prompt('Description:', '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>');
const form = new FormData();
form.append('name', name);
form.append('description', desc || '');
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/update', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
});
}
function deleteTeam() {
if (!confirm('Delete this team? Members will lose access to assigned servers.')) return;
document.getElementById('postForm').action = '/teams/<?= $team['id'] ?>/delete';
document.getElementById('postForm').submit();
}
function addMember(e) {
e.preventDefault();
const userId = document.getElementById('addUserId').value;
if (!userId) return;
const form = new FormData();
form.append('user_id', userId);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/add-user', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
});
}
function removeMember(userId, username) {
if (!confirm('Remove "' + username + '" from this team?')) return;
const form = new FormData();
form.append('user_id', userId);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/remove-user', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
});
}
function addServer(e) {
e.preventDefault();
const serverId = document.getElementById('addServerId').value;
const role = document.getElementById('addServerRole').value;
if (!serverId) return;
const form = new FormData();
form.append('server_id', serverId);
form.append('role', role);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/add-server', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
});
}
function removeServer(serverId, name) {
if (!confirm('Remove "' + name + '" from this team?')) return;
const form = new FormData();
form.append('server_id', serverId);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/remove-server', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
});
}
function updateServerRole(serverId, role) {
const form = new FormData();
form.append('server_id', serverId);
form.append('role', role);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/update-server-role', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
body: form,
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', 'Role updated to ' + role); }
else { showToast('error', res.message); }
});
}
</script>