diff --git a/.github/README.md b/.github/README.md index cb95945b3..529477e88 100755 --- a/.github/README.md +++ b/.github/README.md @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please -_Last updated: 2026-05-20T18:10:33.759Z_ +_Last updated: 2026-06-09T14:28:24.443Z_ | Extension | Files | Lines | | --- | ---: | ---: | -| `.php` | 518 | 132,170 | -| `.tsx` | 368 | 118,043 | -| `.ts` | 77 | 9,333 | -| `.yaml` | 3 | 5,950 | +| `.php` | 534 | 135,333 | +| `.tsx` | 379 | 120,491 | +| `.ts` | 87 | 10,391 | +| `.yaml` | 3 | 5,911 | | `.rs` | 16 | 3,395 | -| `.sql` | 140 | 2,403 | +| `.sql` | 145 | 2,479 | | `.yml` | 18 | 1,877 | -| `.css` | 7 | 445 | -| **Total** | 1,147 | 273,616 | +| `.css` | 7 | 459 | +| **Total** | 1,189 | 280,336 | diff --git a/.vscode/settings.json b/.vscode/settings.json index 786ff918c..6408eefc7 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,13 @@ { + "typescript.tsserver.maxTsServerMemory": 2048, + "files.watcherExclude": { + "**/.next/**": true, + "**/node_modules/**": true + }, + "search.exclude": { + "**/.next": true, + "**/node_modules": true + }, "json.maxItemsComputed": 10000, "editor.largeFileOptimizations": false, "editor.renderWhitespace": "all", diff --git a/CHANGELOG.md b/CHANGELOG.md index 993cadaac..118c6a50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## v1.3.7.4 STABLE + +### Added + +- Configurable default avatar provider in admin settings (Gravatar by default, with panel logo, UI Avatars, RoboHash, DiceBear, and custom URL template support). by @nayskutzu +- Admin SSO token settings: configure the default token lifetime in App settings, and optionally pass `expires_in` (minutes, 1–1440) when calling `POST /api/admin/users/{uuid}/sso-token`. by @nayskutzu +- Admin list pages (users, servers, VDS nodes, nodes, VM instances, spells, roles, tickets, and others) now remember search, filters, sort, and pagination in localStorage. by @nayskutzu +- Spells now support a configurable default Docker image (star icon on the spell Docker tab) so new servers use the correct runtime on first install when game images are updated. by @nayskutzu +- Server startup Docker image selection is locked to spell-configured images by default; admins can enable custom Docker image input under Settings → Servers (`server_allow_custom_docker_image`, default off). by @nayskutzu +- Admin server edit now configures Docker image on the Startup tab; create validates the selected image on submit so spell defaults (e.g. Java 25) persist correctly. by @nayskutzu +- Admin settings to configure the login page default sign-in method, display order of authentication options, and hidden methods (local, LDAP, passkey, email code, Discord, OIDC). by @nayskutzu +- Admins now get clearer notifications (including email notifications) when there are open support tickets; the ticket section can show all open tickets if the user is an admin. by @nayskutzu +- Users now receive an email notification when their support ticket is replied to, closed, or reopened. by @nayskutzu +- Added Discord to the links section in settings. displaying these links in a visible area, such as a footer, so users can easily access them. +- Added a button so you can retry sending an email to a user. by @nayskutzu +- Added the you accept our terms of service checkbox to the login page. by @nayskutzu +- Added a custom badge system to roles so you can set a custom badge for each role. by @nayskutzu + +### Improved + +- Login page CAPTCHA is shown after username/password (before Sign in), full-width in a bordered block, instead of between the two fields. by @nayskutzu +- Styles for the auth LDAP button were improved. by @nayskutzu +- Improved the amazing new role editor page that allows you to edit roles and permissions and thats idiot proof (I HOPE). by @nayskutzu +- Timezones were missmatched into the server backups page. by @nayskutzu +- Now you can sort users by newest and oldest and by username. by @nayskutzu +- Now you can click on the owner's name in the server details page to view the user's profile. by @nayskutzu +- Admin user edit page now includes a Potential Alts tab that finds other accounts sharing IP addresses with the viewed user. by @nayskutzu +- Potential alt detection now also compares server activity IPs and hidden browser sync identifiers (localStorage + cookie) collected across the panel. by @nayskutzu +- Registration can block new accounts when a browser/device already has the maximum allowed panel accounts, pointing users to their main account or support. by @nayskutzu +- Smarter component rendering for improved performance and reliability. by @nayskutzu +- Admins can clear device fingerprint records per user or globally from the Users admin area. by @nayskutzu +- Hopefully fixed some issues with the server console. by @nayskutzu +- Improved phpMyAdmin integration for seamless database management within the panel. by @nayskutzu + +### Fixed + +- Plugin uploads with mismatched conf.yml identifier/name and PHP entry class namespace now fail validation at install time instead of bricking the panel into maintenance mode. by @nayskutzu +- User server startup page now shows admin-assigned Docker images (e.g. Java 25) even when they are not in the spell image list, matching admin server edit behavior. by @nayskutzu +- Admin spell export now downloads the PTDL_v2-compatible export file instead of the raw database record, and legacy raw exports can still be imported. by @nayskutzu +- Admin Updates page plugin bulk updates now call the correct online install API (`/api/admin/plugins/online/install`) instead of a non-existent route. by @nayskutzu +- Issues related to Discord OAuth2 account linking and registration were fixed. by @nayskutzu +- Small issues regarding spells export behavior were fixed. by @nayskutzu +- Node connections now actually respect the behind proxy setting. by @nayskutzu +- Timezones were missmatched into the admin pages.by @nayskutzu +- Search bar in the server navbar was not working. by @nayskutzu +- Plugin behaviors and errors reporting was improved. by @nayskutzu +- Multiple issues with the server transfer dialog were fixed. by @nayskutzu +- Multiple issues with connections to the nodes were fixed. by @nayskutzu + ## v1.3.7.3 STABLE ### Added @@ -33,6 +82,7 @@ - Further enhanced the plugin installer for improved performance and reliability. by @nayskutzu - Resolved a significant issue that previously prevented searching for allocations within the admin area. by @nayskutzu + ### Improved - Tickets were pretty cramped and didn't look good on big screens. by @nayskutzu diff --git a/app b/app index 2fbc3bcd6..811b26b69 100755 --- a/app +++ b/app @@ -48,7 +48,7 @@ define('APP_ADDONS_DIR', APP_STORAGE_DIR . 'addons'); define('APP_SOURCECODE_DIR', APP_DIR . 'app'); define('APP_ROUTES_DIR', APP_SOURCECODE_DIR . '/Api'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.3'); +define('APP_VERSION', 'v1.3.7.4'); define('APP_UPSTREAM', 'stable'); define('TELEMETRY', true); define('IS_CLI', true); diff --git a/backend/app/App.php b/backend/app/App.php index 577ef9ae0..c7657c316 100755 --- a/backend/app/App.php +++ b/backend/app/App.php @@ -250,7 +250,7 @@ public function registerApiRoutes(RouteCollection $routes): void if (is_callable($register)) { $register($routes); } - } catch (\Exception $e) { + } catch (\Throwable $e) { self::getLogger()->error('Failed to load plugin routes from ' . $file->getPathname() . ': ' . $e->getMessage()); } } diff --git a/backend/app/Chat/Activity.php b/backend/app/Chat/Activity.php index afd7df074..7cb05b500 100755 --- a/backend/app/Chat/Activity.php +++ b/backend/app/Chat/Activity.php @@ -102,6 +102,21 @@ public static function getActivitiesByContextLikeAndNameIn(string $contextLike, return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + /** + * @return string[] + */ + public static function getDistinctIpsByUserUuid(string $userUuid): array + { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT DISTINCT ip_address FROM ' . self::$table + . ' WHERE user_uuid = :user_uuid AND ip_address IS NOT NULL AND ip_address != \'\' ORDER BY ip_address' + ); + $stmt->execute(['user_uuid' => $userUuid]); + + return array_column($stmt->fetchAll(\PDO::FETCH_ASSOC), 'ip_address'); + } + public static function getCountByUserUuid(string $userUuid, ?string $search = null): int { $pdo = Database::getPdoConnection(); diff --git a/backend/app/Chat/Allocation.php b/backend/app/Chat/Allocation.php index 1f81535f6..fecf24746 100755 --- a/backend/app/Chat/Allocation.php +++ b/backend/app/Chat/Allocation.php @@ -238,6 +238,43 @@ public static function getFreeCountByNodeId(int $nodeId): int * * @return array */ + /** + * Pick free allocation IDs that match specific IP/port slots (in order). + * + * @param array $slots + * @param array $excludeIds + * + * @return array + */ + public static function pickFreeAllocationIdsForSlots(int $nodeId, array $slots, array $excludeIds = []): array + { + if ($nodeId <= 0 || empty($slots)) { + return []; + } + + $excludeIds = array_values(array_filter( + array_map('intval', $excludeIds), + fn (int $id) => $id > 0 + )); + + $picked = []; + foreach ($slots as $slot) { + $allocation = self::getByNodeIpPort($nodeId, (string) $slot['ip'], (int) $slot['port']); + if ($allocation === null || $allocation['server_id'] !== null) { + return []; + } + + $id = (int) $allocation['id']; + if (in_array($id, $excludeIds, true) || in_array($id, $picked, true)) { + return []; + } + + $picked[] = $id; + } + + return $picked; + } + public static function pickFreeAllocationIdsForNode(int $nodeId, int $count, array $excludeIds = []): array { if ($nodeId <= 0 || $count <= 0) { @@ -604,6 +641,28 @@ public static function deleteUnused(?int $nodeId = null, ?string $ip = null): in return $stmt->rowCount(); } + /** + * Get allocation by node ID, IP, and port. + */ + public static function getByNodeIpPort(int $nodeId, string $ip, int $port): ?array + { + if ($nodeId <= 0) { + return null; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT * FROM ' . self::$table . ' WHERE node_id = :node_id AND ip = :ip AND port = :port LIMIT 1' + ); + $stmt->execute([ + 'node_id' => $nodeId, + 'ip' => $ip, + 'port' => $port, + ]); + + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + /** * Check if IP and port combination is unique for a node. */ diff --git a/backend/app/Chat/MailQueue.php b/backend/app/Chat/MailQueue.php index e9eab1d1b..50a773981 100755 --- a/backend/app/Chat/MailQueue.php +++ b/backend/app/Chat/MailQueue.php @@ -175,4 +175,31 @@ public static function deleteAllMailQueueByUserId(string $userUuid): bool return $stmt->execute(['user_uuid' => $userUuid]); } + + /** + * Re-queue a failed mail for delivery. + */ + public static function retry(int $id): bool + { + if ($id <= 0) { + return false; + } + + $mail = self::getById($id); + if (!$mail || ($mail['status'] ?? '') !== 'failed' || ($mail['deleted'] ?? 'false') === 'true') { + return false; + } + + $updated = self::update($id, [ + 'status' => 'pending', + 'locked' => 'false', + ]); + if (!$updated) { + return false; + } + + \App\Helpers\IAsyncRunnerService::notifyMailPending($id); + + return true; + } } diff --git a/backend/app/Chat/Permission.php b/backend/app/Chat/Permission.php index b58c7f3f4..9a0b7c32f 100755 --- a/backend/app/Chat/Permission.php +++ b/backend/app/Chat/Permission.php @@ -140,4 +140,23 @@ public static function getCountByRoleId(int $roleId): int return (int) $stmt->fetchColumn(); } + + /** + * Get distinct role IDs that have a specific permission or admin.root. + * + * @return int[] + */ + public static function getRoleIdsWithPermission(string $permission): array + { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT DISTINCT role_id FROM ' . self::$table . ' WHERE permission = :permission OR permission = :root' + ); + $stmt->execute([ + 'permission' => $permission, + 'root' => 'admin.root', + ]); + + return array_map(static fn (array $row): int => (int) $row['role_id'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); + } } diff --git a/backend/app/Chat/Role.php b/backend/app/Chat/Role.php index 78cec0e92..68ad69cdb 100755 --- a/backend/app/Chat/Role.php +++ b/backend/app/Chat/Role.php @@ -32,7 +32,7 @@ public static function getAll(?string $search = null, int $limit = 10, int $offs $params['search'] = '%' . $search . '%'; } - $sql .= ' LIMIT :limit OFFSET :offset'; + $sql .= ' ORDER BY id DESC LIMIT :limit OFFSET :offset'; $stmt = $pdo->prepare($sql); if (!empty($params)) { foreach ($params as $key => $value) { diff --git a/backend/app/Chat/Server.php b/backend/app/Chat/Server.php index 02a090923..5cb7de4ec 100755 --- a/backend/app/Chat/Server.php +++ b/backend/app/Chat/Server.php @@ -416,6 +416,7 @@ public static function getServerByAllocationId(int $allocationId): ?array * @param string|null $uuid Filter by UUID (optional) * @param string|null $uuidShort Filter by short UUID (optional) * @param string|null $externalId Filter by external ID (optional) + * @param string|null $status Filter by server status (optional) */ public static function searchServers( int $page = 1, @@ -433,6 +434,7 @@ public static function searchServers( ?string $uuid = null, ?string $uuidShort = null, ?string $externalId = null, + ?string $status = null, ): array { $pdo = Database::getPdoConnection(); @@ -496,6 +498,11 @@ public static function searchServers( $params['external_id'] = $externalId; } + if ($status !== null && $status !== '') { + $where[] = 'status = :status'; + $params['status'] = $status; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } @@ -716,6 +723,7 @@ public static function getAllServersWithRelations(): array * Get the total number of servers. * * @param int|null $excludeOwnerId Exclude servers owned by this user ID (optional) + * @param string|null $status Filter by server status (optional) */ public static function getCount( string $search = '', @@ -728,6 +736,7 @@ public static function getCount( ?string $uuid = null, ?string $uuidShort = null, ?string $externalId = null, + ?string $status = null, ): int { $pdo = Database::getPdoConnection(); $sql = 'SELECT COUNT(*) FROM ' . self::$table; @@ -784,6 +793,11 @@ public static function getCount( $params['external_id'] = $externalId; } + if ($status !== null && $status !== '') { + $where[] = 'status = :status'; + $params['status'] = $status; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } diff --git a/backend/app/Chat/ServerActivity.php b/backend/app/Chat/ServerActivity.php index 21fa53f7e..2d769075f 100755 --- a/backend/app/Chat/ServerActivity.php +++ b/backend/app/Chat/ServerActivity.php @@ -203,6 +203,25 @@ public static function getActivitiesByEvent(string $event, int $limit = 100): ar return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + /** + * @return string[] + */ + public static function getDistinctIpsByUserId(int $userId): array + { + if ($userId <= 0) { + return []; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT DISTINCT ip FROM ' . self::$table + . ' WHERE user_id = :user_id AND ip IS NOT NULL AND ip != \'\' ORDER BY ip' + ); + $stmt->execute(['user_id' => $userId]); + + return array_column($stmt->fetchAll(\PDO::FETCH_ASSOC), 'ip'); + } + /** * Get activities by user ID. * diff --git a/backend/app/Chat/Spell.php b/backend/app/Chat/Spell.php index 30bc1c1cc..016a13ab6 100755 --- a/backend/app/Chat/Spell.php +++ b/backend/app/Chat/Spell.php @@ -599,6 +599,73 @@ public static function count(array $conditions): int return (int) $stmt->fetchColumn(); } + /** + * Parse docker_images JSON into a list of image tags. + * + * @return list + */ + public static function parseDockerImages(?string $dockerImagesJson): array + { + if ($dockerImagesJson === null || trim($dockerImagesJson) === '') { + return []; + } + + try { + $dockerImages = json_decode($dockerImagesJson, true); + if (!is_array($dockerImages) || $dockerImages === []) { + return []; + } + + return array_values(array_filter( + $dockerImages, + static fn ($value) => is_string($value) && trim($value) !== '' + )); + } catch (\Exception $e) { + return []; + } + } + + /** + * Resolve the default Docker image for a spell. + * Uses default_docker_image when set and valid, otherwise the first docker_images entry. + */ + public static function resolveDefaultDockerImage(array $spell): ?string + { + $dockerImages = self::parseDockerImages($spell['docker_images'] ?? null); + + if (!empty($spell['default_docker_image']) && is_string($spell['default_docker_image'])) { + $default = trim($spell['default_docker_image']); + if ($default !== '' && ($dockerImages === [] || in_array($default, $dockerImages, true))) { + return $default; + } + } + + return $dockerImages[0] ?? null; + } + + /** + * Ensure default_docker_image references a value from docker_images when possible. + */ + public static function sanitizeDefaultDockerImage(?string $defaultDockerImage, ?string $dockerImagesJson): ?string + { + $images = self::parseDockerImages($dockerImagesJson); + if ($images === []) { + return null; + } + + if ($defaultDockerImage === null || trim($defaultDockerImage) === '') { + return $images[0]; + } + + $defaultDockerImage = trim($defaultDockerImage); + + if (!in_array($defaultDockerImage, $images, true)) { + return $images[0]; + } + + return $defaultDockerImage; + } + /** * Sanitize data for logging by excluding sensitive fields. */ diff --git a/backend/app/Chat/Ticket.php b/backend/app/Chat/Ticket.php index 60aa6ef0b..a71069cf3 100755 --- a/backend/app/Chat/Ticket.php +++ b/backend/app/Chat/Ticket.php @@ -46,6 +46,7 @@ public static function getAll( ?int $serverId = null, ?int $categoryId = null, ?int $statusId = null, + bool $openOnly = false, ): array { $pdo = Database::getPdoConnection(); $sql = 'SELECT * FROM ' . self::$table; @@ -77,6 +78,10 @@ public static function getAll( $params['status_id'] = $statusId; } + if ($openOnly) { + $where[] = 'closed_at IS NULL'; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } @@ -153,6 +158,7 @@ public static function getCount( ?int $serverId = null, ?int $categoryId = null, ?int $statusId = null, + bool $openOnly = false, ): int { $pdo = Database::getPdoConnection(); $sql = 'SELECT COUNT(*) FROM ' . self::$table; @@ -184,6 +190,10 @@ public static function getCount( $params['status_id'] = $statusId; } + if ($openOnly) { + $where[] = 'closed_at IS NULL'; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } @@ -198,6 +208,19 @@ public static function getCount( return (int) $stmt->fetchColumn(); } + /** + * Get count of all open tickets (where closed_at IS NULL). + */ + public static function getGlobalOpenTicketsCount(): int + { + $pdo = Database::getPdoConnection(); + $sql = 'SELECT COUNT(*) FROM ' . self::$table . ' WHERE closed_at IS NULL'; + $stmt = $pdo->prepare($sql); + $stmt->execute(); + + return (int) $stmt->fetchColumn(); + } + /** * Get count of open tickets for a user (where closed_at IS NULL). * diff --git a/backend/app/Chat/User.php b/backend/app/Chat/User.php index 5199188f9..fa9753199 100755 --- a/backend/app/Chat/User.php +++ b/backend/app/Chat/User.php @@ -18,6 +18,7 @@ namespace App\Chat; use App\App; +use App\Helpers\AvatarHelper; use App\Config\ConfigInterface; /** @@ -148,7 +149,7 @@ public static function getUserById(int $id): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $id]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -163,7 +164,7 @@ public static function getUserByEmail(string $email): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE email = :email LIMIT 1'); $stmt->execute(['email' => $email]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -178,6 +179,30 @@ public static function getAllUsers(bool $includeDeleted = false): array } $stmt = $pdo->query($sql); + return AvatarHelper::enrichUsers($stmt->fetchAll(\PDO::FETCH_ASSOC)); + } + + /** + * Get active (non-deleted, non-banned) users for the given role IDs. + * + * @param int[] $roleIds + * + * @return array + */ + public static function getActiveUsersByRoleIds(array $roleIds): array + { + $roleIds = array_values(array_filter(array_map(static fn ($id) => (int) $id, $roleIds), static fn (int $id) => $id > 0)); + if ($roleIds === []) { + return []; + } + + $pdo = Database::getPdoConnection(); + $placeholders = implode(',', array_fill(0, count($roleIds), '?')); + $sql = 'SELECT uuid, email, first_name, last_name, username, role_id FROM ' . self::$table + . " WHERE role_id IN ($placeholders) AND deleted = 'false' AND banned = 'false'"; + $stmt = $pdo->prepare($sql); + $stmt->execute($roleIds); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } @@ -205,6 +230,8 @@ public static function searchUsers( ?int $userId = null, ?string $uuid = null, ?string $externalId = null, + ?string $ip = null, + ?bool $emailVerified = null, ): array { $pdo = Database::getPdoConnection(); @@ -253,6 +280,17 @@ public static function searchUsers( $params['external_id'] = $externalId; } + if ($ip !== null && $ip !== '') { + $where[] = '(last_ip LIKE :ip OR first_ip LIKE :ip)'; + $params['ip'] = '%' . $ip . '%'; + } + + if ($emailVerified === true) { + $where[] = "(mail_verify IS NULL OR mail_verify = '')"; + } elseif ($emailVerified === false) { + $where[] = "(mail_verify IS NOT NULL AND mail_verify != '')"; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } @@ -272,7 +310,7 @@ public static function searchUsers( $stmt->execute(); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); + return AvatarHelper::enrichUsers($stmt->fetchAll(\PDO::FETCH_ASSOC)); } /** @@ -376,7 +414,7 @@ public static function getUserByUsername(string $username): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE username = :username LIMIT 1'); $stmt->execute(['username' => $username]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -388,7 +426,7 @@ public static function getUserByUuid(string $uuid): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE uuid = :uuid LIMIT 1'); $stmt->execute(['uuid' => $uuid]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -400,7 +438,7 @@ public static function getUserByMailVerify(string $mailVerify): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE mail_verify = :mail_verify LIMIT 1'); $stmt->execute(['mail_verify' => $mailVerify]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } public static function getUserByExternalId(string $externalId): ?array @@ -409,7 +447,7 @@ public static function getUserByExternalId(string $externalId): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE external_id = :external_id LIMIT 1'); $stmt->execute(['external_id' => $externalId]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -421,7 +459,7 @@ public static function getUserByLdapProviderAndDn(string $providerUuid, string $ $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE ldap_provider_uuid = :provider_uuid AND ldap_dn = :dn LIMIT 1'); $stmt->execute(['provider_uuid' => $providerUuid, 'dn' => $dn]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } /** @@ -433,7 +471,7 @@ public static function getUserByRememberToken(string $rememberToken): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE remember_token = :remember_token LIMIT 1'); $stmt->execute(['remember_token' => $rememberToken]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + return AvatarHelper::enrichUser($stmt->fetch(\PDO::FETCH_ASSOC) ?: null); } public static function getColumns(): array @@ -455,6 +493,8 @@ public static function getCount( ?int $userId = null, ?string $uuid = null, ?string $externalId = null, + ?string $ip = null, + ?bool $emailVerified = null, ): int { $pdo = Database::getPdoConnection(); $sql = 'SELECT COUNT(*) FROM ' . self::$table; @@ -492,6 +532,17 @@ public static function getCount( $params['external_id'] = $externalId; } + if ($ip !== null && $ip !== '') { + $where[] = '(last_ip LIKE :ip OR first_ip LIKE :ip)'; + $params['ip'] = '%' . $ip . '%'; + } + + if ($emailVerified === true) { + $where[] = "(mail_verify IS NULL OR mail_verify = '')"; + } elseif ($emailVerified === false) { + $where[] = "(mail_verify IS NOT NULL AND mail_verify != '')"; + } + if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } @@ -557,4 +608,175 @@ public static function generateUuid(): string substr($hex, 20, 12) ); } + + /** + * Find other users that may be alts by comparing IP addresses from panel activity, + * server activity, first/last IP fields, and browser/device sync identifiers. + * + * @return array{ + * source_ips: string[], + * source_devices: string[], + * potential_alts: array> + * } + */ + public static function findPotentialAltsByUuid(string $userUuid): array + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $userUuid)) { + return ['source_ips' => [], 'source_devices' => [], 'potential_alts' => []]; + } + + $user = self::getUserByUuid($userUuid); + if (!$user) { + return ['source_ips' => [], 'source_devices' => [], 'potential_alts' => []]; + } + + $ignoredIps = ['127.0.0.1', '::1', '0.0.0.0', '']; + $sourceIps = Activity::getDistinctIpsByUserUuid($userUuid); + $sourceIps = array_merge($sourceIps, ServerActivity::getDistinctIpsByUserId((int) $user['id'])); + + foreach (['first_ip', 'last_ip'] as $field) { + $ip = trim((string) ($user[$field] ?? '')); + if ($ip !== '' && !in_array($ip, $ignoredIps, true)) { + $sourceIps[] = $ip; + } + } + + $sourceIps = array_values(array_unique(array_filter( + $sourceIps, + static fn ($ip) => is_string($ip) + && trim($ip) !== '' + && !in_array(trim($ip), $ignoredIps, true) + ))); + + $sourceDevices = array_values(array_unique(array_merge( + UserDevice::getDeviceHashesByUserUuid($userUuid), + UserDevice::getSignalHashesByUserUuid($userUuid), + ))); + + if (empty($sourceIps) && empty($sourceDevices)) { + return ['source_ips' => [], 'source_devices' => [], 'potential_alts' => []]; + } + + $pdo = Database::getPdoConnection(); + $ipMatches = []; + if (!empty($sourceIps)) { + $placeholders = implode(',', array_fill(0, count($sourceIps), '?')); + + $sql = 'SELECT u.uuid, u.username, u.email, u.avatar, u.banned, u.first_ip, u.last_ip, u.last_seen, u.role_id, + a.ip_address AS shared_ip, \'panel_activity\' AS match_source + FROM featherpanel_activity a + INNER JOIN ' . self::$table . ' u ON u.uuid = a.user_uuid + WHERE a.ip_address IN (' . $placeholders . ') AND u.uuid != ?'; + $params = array_merge($sourceIps, [$userUuid]); + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $ipMatches = array_merge($ipMatches, $stmt->fetchAll(\PDO::FETCH_ASSOC)); + + $sqlServer = 'SELECT u.uuid, u.username, u.email, u.avatar, u.banned, u.first_ip, u.last_ip, u.last_seen, u.role_id, + sa.ip AS shared_ip, \'server_activity\' AS match_source + FROM featherpanel_server_activities sa + INNER JOIN ' . self::$table . ' u ON u.id = sa.user_id + WHERE sa.ip IN (' . $placeholders . ') AND u.uuid != ?'; + $stmtServer = $pdo->prepare($sqlServer); + $stmtServer->execute($params); + $ipMatches = array_merge($ipMatches, $stmtServer->fetchAll(\PDO::FETCH_ASSOC)); + + $sql2 = 'SELECT uuid, username, email, avatar, banned, first_ip, last_ip, last_seen, role_id, + first_ip AS shared_ip, \'user_ip\' AS match_source + FROM ' . self::$table . ' + WHERE uuid != ? AND first_ip IN (' . $placeholders . ') + UNION ALL + SELECT uuid, username, email, avatar, banned, first_ip, last_ip, last_seen, role_id, + last_ip AS shared_ip, \'user_ip\' AS match_source + FROM ' . self::$table . ' + WHERE uuid != ? AND last_ip IN (' . $placeholders . ')'; + $params2 = array_merge([$userUuid], $sourceIps, [$userUuid], $sourceIps); + $stmt2 = $pdo->prepare($sql2); + $stmt2->execute($params2); + $ipMatches = array_merge($ipMatches, $stmt2->fetchAll(\PDO::FETCH_ASSOC)); + } + + $deviceHashes = UserDevice::getDeviceHashesByUserUuid($userUuid); + $deviceMatches = UserDevice::findUsersByDeviceHashes($deviceHashes, $userUuid); + foreach ($deviceMatches as &$row) { + $row['match_source'] = 'device_sync'; + } + unset($row); + + $signalHashes = UserDevice::getSignalHashesByUserUuid($userUuid); + $signalMatches = UserDevice::findUsersBySignalHashes($signalHashes, $userUuid); + foreach ($signalMatches as &$row) { + $row['match_source'] = 'device_profile'; + } + unset($row); + + $altsMap = []; + foreach (array_merge($ipMatches, $deviceMatches, $signalMatches) as $row) { + $uuid = $row['uuid']; + if (!isset($altsMap[$uuid])) { + $altsMap[$uuid] = [ + 'uuid' => $uuid, + 'username' => $row['username'], + 'email' => $row['email'], + 'avatar' => $row['avatar'], + 'banned' => $row['banned'], + 'first_ip' => $row['first_ip'], + 'last_ip' => $row['last_ip'], + 'last_seen' => $row['last_seen'], + 'role_id' => $row['role_id'], + 'shared_ips' => [], + 'shared_devices' => [], + 'match_reasons' => [], + ]; + } + + $matchSource = (string) ($row['match_source'] ?? ''); + if ($matchSource !== '' && !in_array($matchSource, $altsMap[$uuid]['match_reasons'], true)) { + $altsMap[$uuid]['match_reasons'][] = $matchSource; + } + + if (isset($row['shared_ip'])) { + $sharedIp = trim((string) $row['shared_ip']); + if ($sharedIp !== '' && !in_array($sharedIp, $altsMap[$uuid]['shared_ips'], true)) { + $altsMap[$uuid]['shared_ips'][] = $sharedIp; + } + } + + if (isset($row['shared_device'])) { + $sharedDevice = trim((string) $row['shared_device']); + if ($sharedDevice !== '' && !in_array($sharedDevice, $altsMap[$uuid]['shared_devices'], true)) { + $altsMap[$uuid]['shared_devices'][] = $sharedDevice; + } + } + } + + $alts = array_values($altsMap); + usort($alts, static function (array $a, array $b): int { + $scoreA = count($a['shared_ips']) + (count($a['shared_devices']) * 2); + $scoreB = count($b['shared_ips']) + (count($b['shared_devices']) * 2); + $cmp = $scoreB <=> $scoreA; + if ($cmp !== 0) { + return $cmp; + } + + return strcmp((string) $a['username'], (string) $b['username']); + }); + + foreach ($alts as &$alt) { + sort($alt['shared_ips']); + sort($alt['shared_devices']); + sort($alt['match_reasons']); + $alt['match_count'] = count($alt['shared_ips']) + count($alt['shared_devices']); + $alt['confidence'] = in_array('device_sync', $alt['match_reasons'], true) + && !empty($alt['shared_ips']) ? 'high' + : (in_array('device_sync', $alt['match_reasons'], true) || in_array('device_profile', $alt['match_reasons'], true) ? 'medium' : 'low'); + } + unset($alt); + + return [ + 'source_ips' => $sourceIps, + 'source_devices' => $sourceDevices, + 'potential_alts' => $alts, + ]; + } } diff --git a/backend/app/Chat/UserDevice.php b/backend/app/Chat/UserDevice.php new file mode 100644 index 000000000..e6ebd4b82 --- /dev/null +++ b/backend/app/Chat/UserDevice.php @@ -0,0 +1,271 @@ +. + */ + +namespace App\Chat; + +use App\App; + +class UserDevice +{ + private static string $table = 'featherpanel_user_devices'; + + /** + * @return string[] + */ + public static function getDeviceHashesByUserUuid(string $userUuid): array + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $userUuid)) { + return []; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT DISTINCT device_hash FROM ' . self::$table . ' WHERE user_uuid = :user_uuid ORDER BY device_hash' + ); + $stmt->execute(['user_uuid' => $userUuid]); + + return array_column($stmt->fetchAll(\PDO::FETCH_ASSOC), 'device_hash'); + } + + /** + * @return string[] + */ + public static function getSignalHashesByUserUuid(string $userUuid): array + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $userUuid)) { + return []; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT DISTINCT signal_hash FROM ' . self::$table + . ' WHERE user_uuid = :user_uuid AND signal_hash IS NOT NULL AND signal_hash != \'\' ORDER BY signal_hash' + ); + $stmt->execute(['user_uuid' => $userUuid]); + + return array_column($stmt->fetchAll(\PDO::FETCH_ASSOC), 'signal_hash'); + } + + /** + * @param string[] $deviceHashes + * + * @return array> + */ + public static function findUsersByDeviceHashes(array $deviceHashes, string $excludeUserUuid): array + { + $deviceHashes = array_values(array_unique(array_filter($deviceHashes, static fn ($h) => is_string($h) && preg_match('/^[a-f0-9]{64}$/i', $h)))); + if (empty($deviceHashes)) { + return []; + } + + $pdo = Database::getPdoConnection(); + $placeholders = implode(',', array_fill(0, count($deviceHashes), '?')); + $sql = 'SELECT u.uuid, u.username, u.email, u.avatar, u.banned, u.first_ip, u.last_ip, u.last_seen, u.role_id, + ud.device_hash AS shared_device + FROM ' . self::$table . ' ud + INNER JOIN featherpanel_users u ON u.uuid = ud.user_uuid + WHERE ud.device_hash IN (' . $placeholders . ') AND u.uuid != ?'; + $params = array_merge($deviceHashes, [$excludeUserUuid]); + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * @param string[] $signalHashes + * + * @return array> + */ + public static function findUsersBySignalHashes(array $signalHashes, string $excludeUserUuid): array + { + $signalHashes = array_values(array_unique(array_filter($signalHashes, static fn ($h) => is_string($h) && preg_match('/^[a-f0-9]{64}$/i', $h)))); + if (empty($signalHashes)) { + return []; + } + + $pdo = Database::getPdoConnection(); + $placeholders = implode(',', array_fill(0, count($signalHashes), '?')); + $sql = 'SELECT u.uuid, u.username, u.email, u.avatar, u.banned, u.first_ip, u.last_ip, u.last_seen, u.role_id, + ud.signal_hash AS shared_device + FROM ' . self::$table . ' ud + INNER JOIN featherpanel_users u ON u.uuid = ud.user_uuid + WHERE ud.signal_hash IN (' . $placeholders . ') AND u.uuid != ?'; + $params = array_merge($signalHashes, [$excludeUserUuid]); + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + public static function hashClientToken(string $clientToken): string + { + return hash('sha256', 'fp-ui:' . strtolower(trim($clientToken))); + } + + public static function countDistinctUsersByDeviceHash(string $deviceHash): int + { + if (!preg_match('/^[a-f0-9]{64}$/i', $deviceHash)) { + return 0; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT COUNT(DISTINCT user_uuid) FROM ' . self::$table . ' WHERE device_hash = :device_hash' + ); + $stmt->execute(['device_hash' => $deviceHash]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Oldest account seen on this device (by first device visit). + * + * @return array{uuid: string, username: string, email: string}|null + */ + public static function getMainAccountForDeviceHash(string $deviceHash): ?array + { + if (!preg_match('/^[a-f0-9]{64}$/i', $deviceHash)) { + return null; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT u.uuid, u.username, u.email, MIN(ud.first_seen) AS device_first_seen + FROM ' . self::$table . ' ud + INNER JOIN featherpanel_users u ON u.uuid = ud.user_uuid + WHERE ud.device_hash = :device_hash + GROUP BY u.uuid, u.username, u.email + ORDER BY device_first_seen ASC + LIMIT 1' + ); + $stmt->execute(['device_hash' => $deviceHash]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + return [ + 'uuid' => (string) $row['uuid'], + 'username' => (string) $row['username'], + 'email' => (string) $row['email'], + ]; + } + + public static function deleteAll(): bool + { + try { + $pdo = Database::getPdoConnection(); + + return $pdo->exec('DELETE FROM ' . self::$table) !== false; + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Failed to delete all user device data: ' . $e->getMessage()); + + return false; + } + } + + public static function trackVisit( + string $userUuid, + string $clientToken, + ?array $signals, + string $ipAddress, + ?string $userAgent, + ): bool { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $userUuid)) { + return false; + } + + $clientToken = trim($clientToken); + if (!preg_match('/^[a-f0-9\-]{16,64}$/i', $clientToken)) { + return false; + } + + $deviceHash = self::hashClientToken($clientToken); + $signalHash = null; + $signalsJson = null; + + if (!empty($signals)) { + ksort($signals); + $signalHash = hash('sha256', json_encode($signals, JSON_UNESCAPED_SLASHES)); + $signalsJson = json_encode($signals, JSON_UNESCAPED_SLASHES); + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT id, last_seen FROM ' . self::$table + . ' WHERE user_uuid = :user_uuid AND device_hash = :device_hash LIMIT 1' + ); + $stmt->execute(['user_uuid' => $userUuid, 'device_hash' => $deviceHash]); + $existing = $stmt->fetch(\PDO::FETCH_ASSOC); + + if ($existing) { + $lastSeen = strtotime((string) ($existing['last_seen'] ?? '')); + if ($lastSeen !== false && (time() - $lastSeen) < 600) { + return true; + } + + $update = $pdo->prepare( + 'UPDATE ' . self::$table . ' SET last_seen = NOW(), hit_count = hit_count + 1, + ip_address = :ip_address, user_agent = :user_agent' + . ($signalHash !== null ? ', signal_hash = :signal_hash, signals = :signals' : '') + . ' WHERE id = :id' + ); + $params = [ + 'id' => (int) $existing['id'], + 'ip_address' => $ipAddress !== '' ? $ipAddress : null, + 'user_agent' => $userAgent !== null && strlen($userAgent) > 512 ? substr($userAgent, 0, 512) : $userAgent, + ]; + if ($signalHash !== null) { + $params['signal_hash'] = $signalHash; + $params['signals'] = $signalsJson; + } + + return $update->execute($params); + } + + $insert = $pdo->prepare( + 'INSERT INTO ' . self::$table + . ' (user_uuid, device_hash, signal_hash, signals, ip_address, user_agent, first_seen, last_seen, hit_count) + VALUES (:user_uuid, :device_hash, :signal_hash, :signals, :ip_address, :user_agent, NOW(), NOW(), 1)' + ); + + return $insert->execute([ + 'user_uuid' => $userUuid, + 'device_hash' => $deviceHash, + 'signal_hash' => $signalHash, + 'signals' => $signalsJson, + 'ip_address' => $ipAddress !== '' ? $ipAddress : null, + 'user_agent' => $userAgent !== null && strlen($userAgent) > 512 ? substr($userAgent, 0, 512) : $userAgent, + ]); + } + + public static function deleteUserData(string $userUuid): bool + { + try { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE user_uuid = :user_uuid'); + + return $stmt->execute(['user_uuid' => $userUuid]); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Failed to delete user device data: ' . $e->getMessage()); + + return false; + } + } +} diff --git a/backend/app/Cli/Commands/Saas.php b/backend/app/Cli/Commands/Saas.php index 84c69aa62..0cce24609 100755 --- a/backend/app/Cli/Commands/Saas.php +++ b/backend/app/Cli/Commands/Saas.php @@ -23,7 +23,6 @@ use App\Helpers\UUIDUtils; use App\Cli\CommandBuilder; use App\Config\ConfigFactory; -use App\Config\ConfigInterface; use App\Helpers\EmailDomainValidator; class Saas extends App implements CommandBuilder @@ -196,8 +195,6 @@ private static function createUser(array $args): void } $uuid = UUIDUtils::generateV4(); - $config = self::$app->getConfig(); - $avatar = $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png'); $data = [ 'username' => $username, @@ -207,7 +204,6 @@ private static function createUser(array $args): void 'password' => password_hash($password, PASSWORD_BCRYPT), 'uuid' => $uuid, 'remember_token' => User::generateAccountToken(), - 'avatar' => $avatar, 'role_id' => $roleId, ]; diff --git a/backend/app/Cli/Commands/Users.php b/backend/app/Cli/Commands/Users.php index 21123c039..8c619c552 100755 --- a/backend/app/Cli/Commands/Users.php +++ b/backend/app/Cli/Commands/Users.php @@ -29,7 +29,6 @@ use App\Chat\VmInstance; use App\Helpers\UUIDUtils; use App\Cli\CommandBuilder; -use App\Config\ConfigInterface; use App\Helpers\EmailDomainValidator; class Users extends App implements CommandBuilder @@ -374,9 +373,6 @@ private static function createUserInteractive(): void } $roleId = empty($roleIdInput) ? 1 : (int) $roleIdInput; - $config = self::$app->getConfig(); - $avatar = $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png'); - $data = [ 'username' => $username, 'email' => $email, @@ -385,7 +381,6 @@ private static function createUserInteractive(): void 'password' => password_hash($password, PASSWORD_BCRYPT), 'uuid' => UUIDUtils::generateV4(), 'remember_token' => User::generateAccountToken(), - 'avatar' => $avatar, 'role_id' => $roleId, ]; diff --git a/backend/app/Config/ConfigInterface.php b/backend/app/Config/ConfigInterface.php index 70874bf87..a313880c5 100755 --- a/backend/app/Config/ConfigInterface.php +++ b/backend/app/Config/ConfigInterface.php @@ -30,6 +30,8 @@ interface ConfigInterface public const APP_LOGO_DARK = 'app_logo_dark'; public const APP_SUPPORT_URL = 'app_support_url'; public const APP_SSO_REDIRECT_PATH = 'app_sso_redirect_path'; + /** Default SSO login token lifetime in minutes (admin-generated tokens). */ + public const APP_SSO_TOKEN_LIFETIME_MINUTES = 'app_sso_token_lifetime_minutes'; /** When true, VNC wss_url is built from APP_URL so the browser connects to the panel; reverse proxy must forward /vnc-proxy/ to Proxmox. */ public const VNC_PROXY_VIA_PANEL = 'vnc_proxy_via_panel'; /** When true, create a short-lived PVE user, grant console ACL, get ticket and return pve_redirect_url so the frontend can open Proxmox noVNC in the browser. */ @@ -64,6 +66,7 @@ interface ConfigInterface public const TWITTER_URL = 'twitter_url'; public const WHATSAPP_URL = 'whatsapp_url'; public const YOUTUBE_URL = 'youtube_url'; + public const DISCORD_URL = 'discord_url'; public const WEBSITE_URL = 'website_url'; public const STATUS_PAGE_URL = 'status_page_url'; /** @@ -119,6 +122,9 @@ interface ConfigInterface */ public const REGISTRATION_ENABLED = 'registration_enabled'; public const REGISTRATION_REQUIRE_EMAIL_VERIFICATION = 'registration_require_email_verification'; + /** Limit how many panel accounts may register from the same browser/device fingerprint. */ + public const REGISTRATION_DEVICE_LIMIT_ENABLED = 'registration_device_limit_enabled'; + public const REGISTRATION_DEVICE_MAX_ACCOUNTS = 'registration_device_max_accounts'; /** When true, reject registration/email changes whose domain matches featherpanel_blocked_email_domains (admin-managed). */ public const EMAIL_DOMAIN_BLOCKING_ENABLED = 'email_domain_blocking_enabled'; public const REQUIRE_TWO_FA_ADMINS = 'require_two_fa_admins'; @@ -128,6 +134,17 @@ interface ConfigInterface */ public const EMAIL_LOGIN_ENABLED = 'email_login_enabled'; + /** + * Login page layout (public settings exposed via PublicConfig). + * + * login_default_method: local | ldap | email_code | discord | oidc + * login_methods_order: comma-separated method ids (see frontend loginPageConfig) + * login_hidden_methods: comma-separated method ids to hide on the login page + */ + public const LOGIN_DEFAULT_METHOD = 'login_default_method'; + public const LOGIN_METHODS_ORDER = 'login_methods_order'; + public const LOGIN_HIDDEN_METHODS = 'login_hidden_methods'; + /** * Telemetry. */ @@ -183,6 +200,8 @@ interface ConfigInterface public const SERVER_ALLOW_EGG_CHANGE = 'server_allow_egg_change'; public const SERVER_ALLOW_USER_SERVER_DELETION = 'server_allow_user_server_deletion'; public const SERVER_ALLOW_STARTUP_CHANGE = 'server_allow_startup_change'; + /** When true, users may enter a custom Docker image string; otherwise only spell-listed images are allowed. */ + public const SERVER_ALLOW_CUSTOM_DOCKER_IMAGE = 'server_allow_custom_docker_image'; public const SERVER_ALLOW_SUBUSERS = 'server_allow_subusers'; public const SERVER_ALLOW_SCHEDULES = 'server_allow_schedules'; /** @@ -216,6 +235,10 @@ interface ConfigInterface /** * User Related Configs. */ + /** Default avatar provider: gravatar, panel_logo, ui_avatars, robohash, dicebear, custom */ + public const AVATAR_PROVIDER = 'avatar_provider'; + /** Custom avatar URL template (only when avatar_provider is custom). Placeholders: {email}, {username}, {name}, {hash}, {app_url} */ + public const AVATAR_CUSTOM_URL = 'avatar_custom_url'; public const USER_ALLOW_AVATAR_CHANGE = 'user_allow_avatar_change'; public const USER_ALLOW_USERNAME_CHANGE = 'user_allow_username_change'; public const USER_ALLOW_EMAIL_CHANGE = 'user_allow_email_change'; diff --git a/backend/app/Config/PublicConfig.php b/backend/app/Config/PublicConfig.php index 8a8c1fb4f..5b0c4c367 100755 --- a/backend/app/Config/PublicConfig.php +++ b/backend/app/Config/PublicConfig.php @@ -54,6 +54,7 @@ public static function getPublicSettingsWithDefaults(): array ConfigInterface::APP_LOGO_WHITE => 'https://github.com/featherpanel-com.png', ConfigInterface::APP_LOGO_DARK => 'https://github.com/featherpanel-com.png', ConfigInterface::APP_SSO_REDIRECT_PATH => '/', + ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES => '5', // Appearance defaults (safe, non-sensitive) // Optional global background image URL and a lock flag to force it for all users. ConfigInterface::APP_BACKGROUND_IMAGE_URL => '', @@ -101,6 +102,7 @@ public static function getPublicSettingsWithDefaults(): array ConfigInterface::TWITTER_URL => '', ConfigInterface::WHATSAPP_URL => '', ConfigInterface::YOUTUBE_URL => '', + ConfigInterface::DISCORD_URL => '', ConfigInterface::WEBSITE_URL => '', ConfigInterface::STATUS_PAGE_URL => '', @@ -151,6 +153,7 @@ public static function getPublicSettingsWithDefaults(): array // Servers related settings ConfigInterface::SERVER_ALLOW_EGG_CHANGE => 'false', ConfigInterface::SERVER_ALLOW_STARTUP_CHANGE => 'true', + ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE => 'false', ConfigInterface::SERVER_ALLOW_SUBUSERS => 'true', ConfigInterface::SERVER_ALLOW_SCHEDULES => 'true', ConfigInterface::SERVER_LIFECYCLE_HOOKS_ENABLED => 'false', @@ -171,6 +174,8 @@ public static function getPublicSettingsWithDefaults(): array ConfigInterface::FILE_TRASH_RETENTION_DAYS => '30', // User related settings + ConfigInterface::AVATAR_PROVIDER => 'gravatar', + ConfigInterface::AVATAR_CUSTOM_URL => '', ConfigInterface::USER_ALLOW_AVATAR_CHANGE => 'true', ConfigInterface::USER_ALLOW_USERNAME_CHANGE => 'true', ConfigInterface::USER_ALLOW_EMAIL_CHANGE => 'true', @@ -215,6 +220,10 @@ public static function getPublicSettingsWithDefaults(): array // Demo mode settings ConfigInterface::APP_DEMO_YES => 'false', ConfigInterface::EMAIL_LOGIN_ENABLED => 'false', + + ConfigInterface::LOGIN_DEFAULT_METHOD => 'local', + ConfigInterface::LOGIN_METHODS_ORDER => 'local,passkey,ldap,email_code,discord,oidc', + ConfigInterface::LOGIN_HIDDEN_METHODS => '', ]; } } diff --git a/backend/app/Controllers/Admin/CloudPluginsController.php b/backend/app/Controllers/Admin/CloudPluginsController.php index df8ebe29e..2821b2589 100755 --- a/backend/app/Controllers/Admin/CloudPluginsController.php +++ b/backend/app/Controllers/Admin/CloudPluginsController.php @@ -1106,6 +1106,18 @@ public function performAddonInstall(string $tempDir, ?string $identifier = null, return ApiResponse::error('Invalid addon identifier in conf.yml', 'ADDON_IDENTIFIER_INVALID', 422); } + $entryValidation = \App\Plugins\PluginEntryValidator::validatePackage($tempDir, $identifier); + if (!$entryValidation['valid']) { + @exec('rm -rf ' . escapeshellarg($tempDir)); + + return ApiResponse::error( + $entryValidation['errors'][0] ?? 'Invalid addon entry class configuration', + 'ADDON_ENTRY_INVALID', + 422, + ['errors' => $entryValidation['errors']] + ); + } + $pluginDir = APP_ADDONS_DIR . '/' . $identifier; $isUpdate = file_exists($pluginDir); $oldVersion = null; diff --git a/backend/app/Controllers/Admin/DashboardController.php b/backend/app/Controllers/Admin/DashboardController.php index b20cc6e9e..540a53009 100755 --- a/backend/app/Controllers/Admin/DashboardController.php +++ b/backend/app/Controllers/Admin/DashboardController.php @@ -369,7 +369,7 @@ public function index(Request $request): Response ? new \DateTime( $row['last_run_at'], new \DateTimeZone('UTC'), - )->getTimestamp() + )->getTimestamp() ?? null, : null; $expected = $expectedMap[$name] ?? 300; // default 5 minutes if unknown $late = $lastRunAt ? $now - $lastRunAt > $expected * 2 : true; // late if never ran or >2x expected diff --git a/backend/app/Controllers/Admin/PermissionsController.php b/backend/app/Controllers/Admin/PermissionsController.php index 3a3ffcdf0..2414db7c0 100755 --- a/backend/app/Controllers/Admin/PermissionsController.php +++ b/backend/app/Controllers/Admin/PermissionsController.php @@ -17,6 +17,7 @@ namespace App\Controllers\Admin; +use App\Permissions; use App\Chat\Activity; use App\Chat\Permission; use App\Helpers\ApiResponse; @@ -355,6 +356,24 @@ public function update(Request $request, int $id): Response return ApiResponse::error('Permission must be between 2 and 255 characters', 'INVALID_DATA_LENGTH'); } } + + $admin = $request->attributes->get('user'); + if ( + $permission['permission'] === Permissions::ADMIN_ROOT + && isset($admin['role_id']) + && (int) $permission['role_id'] === (int) $admin['role_id'] + && ( + (isset($data['permission']) && $data['permission'] !== Permissions::ADMIN_ROOT) + || (isset($data['role_id']) && (int) $data['role_id'] !== (int) $admin['role_id']) + ) + ) { + return ApiResponse::error( + 'You cannot remove admin.root from your own role', + 'CANNOT_REMOVE_OWN_ADMIN_ROOT', + 403 + ); + } + $success = Permission::updatePermission($id, $data); if (!$success) { return ApiResponse::error('Failed to update permission', 'PERMISSION_UPDATE_FAILED', 400); @@ -422,6 +441,20 @@ public function delete(Request $request, int $id): Response if (!$permission) { return ApiResponse::error('Permission not found', 'PERMISSION_NOT_FOUND', 404); } + + $admin = $request->attributes->get('user'); + if ( + $permission['permission'] === Permissions::ADMIN_ROOT + && isset($admin['role_id']) + && (int) $permission['role_id'] === (int) $admin['role_id'] + ) { + return ApiResponse::error( + 'You cannot remove admin.root from your own role', + 'CANNOT_REMOVE_OWN_ADMIN_ROOT', + 403 + ); + } + $success = Permission::deletePermission($id); if (!$success) { return ApiResponse::error('Failed to delete permission', 'PERMISSION_DELETE_FAILED', 400); diff --git a/backend/app/Controllers/Admin/PterodactylImporterController.php b/backend/app/Controllers/Admin/PterodactylImporterController.php index b670b9fab..4c5e62a7f 100755 --- a/backend/app/Controllers/Admin/PterodactylImporterController.php +++ b/backend/app/Controllers/Admin/PterodactylImporterController.php @@ -38,7 +38,6 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\DatabaseInstance; -use App\Config\ConfigInterface; use App\CloudFlare\CloudFlareRealIP; use App\Plugins\Events\Events\UserEvent; use App\Plugins\Events\Events\NodesEvent; @@ -904,8 +903,6 @@ public function importUser(Request $request): Response } } $roleId = $isRootAdmin ? 4 : 1; // 4 = admin, 1 = user - $config = App::getInstance(true)->getConfig(); - $avatar = $config->getSetting(ConfigInterface::APP_LOGO_DARK, 'https://github.com/featherpanel-com.png'); $userData = [ 'uuid' => $user['uuid'], 'username' => $user['username'], @@ -914,7 +911,6 @@ public function importUser(Request $request): Response 'last_name' => $user['name_last'] ?? '', // Empty string if not provided 'password' => $user['password'], // Bcrypt password from Pterodactyl (compatible) 'remember_token' => $user['remember_token'] ?? User::generateAccountToken(), - 'avatar' => $avatar, // Default avatar 'role_id' => $roleId, // Map root_admin to role_id (4 = admin, 1 = user) 'external_id' => $user['external_id'] ?? null, 'two_fa_enabled' => 'false', // Don't import 2FA - always set to false diff --git a/backend/app/Controllers/Admin/RolesController.php b/backend/app/Controllers/Admin/RolesController.php index 6da93e5ad..e57a2cc3f 100755 --- a/backend/app/Controllers/Admin/RolesController.php +++ b/backend/app/Controllers/Admin/RolesController.php @@ -34,6 +34,7 @@ new OA\Property(property: 'name', type: 'string', description: 'Role name'), new OA\Property(property: 'display_name', type: 'string', description: 'Role display name'), new OA\Property(property: 'color', type: 'string', description: 'Role color'), + new OA\Property(property: 'custom_badge', type: 'string', nullable: true, description: 'Optional short label shown on user badges instead of the display name'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time', description: 'Creation timestamp'), new OA\Property(property: 'updated_at', type: 'string', format: 'date-time', description: 'Last update timestamp'), ] @@ -60,6 +61,7 @@ new OA\Property(property: 'name', type: 'string', description: 'Role name', minLength: 2, maxLength: 255), new OA\Property(property: 'display_name', type: 'string', description: 'Role display name', minLength: 2, maxLength: 255), new OA\Property(property: 'color', type: 'string', description: 'Role color', maxLength: 32), + new OA\Property(property: 'custom_badge', type: 'string', nullable: true, description: 'Optional short label shown on user badges', maxLength: 64), ] )] #[OA\Schema( @@ -69,6 +71,7 @@ new OA\Property(property: 'name', type: 'string', description: 'Role name', minLength: 2, maxLength: 255), new OA\Property(property: 'display_name', type: 'string', description: 'Role display name', minLength: 2, maxLength: 255), new OA\Property(property: 'color', type: 'string', description: 'Role color', maxLength: 32), + new OA\Property(property: 'custom_badge', type: 'string', nullable: true, description: 'Optional short label shown on user badges', maxLength: 64), ] )] class RolesController @@ -256,6 +259,16 @@ public function create(Request $request): Response if (strlen($data['color']) > 32) { return ApiResponse::error('Color must be less than 32 characters', 'INVALID_DATA_LENGTH'); } + if (array_key_exists('custom_badge', $data)) { + $normalizedBadge = $this->normalizeCustomBadge($data['custom_badge']); + if ($normalizedBadge === false) { + return ApiResponse::error('Custom badge must be a string', 'INVALID_DATA_TYPE'); + } + if (is_string($normalizedBadge) && strlen($normalizedBadge) > 64) { + return ApiResponse::error('Custom badge must be less than 64 characters', 'INVALID_DATA_LENGTH'); + } + $data['custom_badge'] = $normalizedBadge; + } $id = Role::createRole($data); if (!$id) { return ApiResponse::error('Failed to create role', 'ROLE_CREATE_FAILED', 400); @@ -360,6 +373,16 @@ public function update(Request $request, int $id): Response return ApiResponse::error('Color must be less than 32 characters', 'INVALID_DATA_LENGTH'); } } + if (array_key_exists('custom_badge', $data)) { + $normalizedBadge = $this->normalizeCustomBadge($data['custom_badge']); + if ($normalizedBadge === false) { + return ApiResponse::error('Custom badge must be a string', 'INVALID_DATA_TYPE'); + } + if (is_string($normalizedBadge) && strlen($normalizedBadge) > 64) { + return ApiResponse::error('Custom badge must be less than 64 characters', 'INVALID_DATA_LENGTH'); + } + $data['custom_badge'] = $normalizedBadge; + } $success = Role::updateRole($id, $data); if (!$success) { return ApiResponse::error('Failed to update role', 'ROLE_UPDATE_FAILED', 400); @@ -459,4 +482,17 @@ public function delete(Request $request, int $id): Response return ApiResponse::success([], 'Role deleted successfully', 200); } + + private function normalizeCustomBadge(mixed $value): string | false | null + { + if ($value === null) { + return null; + } + if (!is_string($value)) { + return false; + } + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } } diff --git a/backend/app/Controllers/Admin/ServersController.php b/backend/app/Controllers/Admin/ServersController.php index b12a3f349..bce792027 100755 --- a/backend/app/Controllers/Admin/ServersController.php +++ b/backend/app/Controllers/Admin/ServersController.php @@ -984,7 +984,7 @@ public function create(Request $request): Response if ($data['memory'] !== 0 && ($data['memory'] < 128 || $data['memory'] > 1048576)) { return ApiResponse::error('Memory must be between 128 MB and 1TB', 'INVALID_MEMORY_LIMIT', 400); } - if ($data['swap'] !== 0 && $data['swap'] !== -1 && $data['swap'] > 1048576) { + if ($data['swap'] !== 0 && $data['swap'] !== -1 && ($data['swap'] < 1 || $data['swap'] > 1048576)) { return ApiResponse::error('Swap must be -1 (unlimited), 0 (disabled), or between 1 MB and 1TB', 'INVALID_SWAP_LIMIT', 400); } if ($data['disk'] !== 0 && ($data['disk'] < 128 || $data['disk'] > 10485760)) { @@ -1495,7 +1495,7 @@ public function update(Request $request, int $id): Response if (isset($data['memory']) && $data['memory'] !== 0 && ($data['memory'] < 128 || $data['memory'] > 1048576)) { return ApiResponse::error('Memory must be between 128 MB and 1TB', 'INVALID_MEMORY_LIMIT', 400); } - if (isset($data['swap']) && $data['swap'] !== 0 && $data['swap'] !== -1 && $data['swap'] > 1048576) { + if (isset($data['swap']) && $data['swap'] !== 0 && $data['swap'] !== -1 && ($data['swap'] < 1 || $data['swap'] > 1048576)) { return ApiResponse::error('Swap must be -1 (unlimited), 0 (disabled), or between 1 MB and 1TB', 'INVALID_SWAP_LIMIT', 400); } if (isset($data['disk']) && $data['disk'] !== 0 && ($data['disk'] < 128 || $data['disk'] > 10485760)) { @@ -1640,16 +1640,9 @@ public function update(Request $request, int $id): Response $serverUpdateData['startup'] = $newSpell['startup']; } if (!isset($data['image']) && !empty($newSpell['docker_images'])) { - try { - $dockerImages = json_decode($newSpell['docker_images'], true); - if (is_array($dockerImages) && $dockerImages !== []) { - $imageArray = array_values($dockerImages); - if (!empty($imageArray[0])) { - $serverUpdateData['image'] = $imageArray[0]; - } - } - } catch (\Exception $e) { - App::getInstance(true)->getLogger()->warning('Failed to parse docker_images for auto-selection: ' . $e->getMessage()); + $resolvedImage = Spell::resolveDefaultDockerImage($newSpell); + if ($resolvedImage !== null) { + $serverUpdateData['image'] = $resolvedImage; } } } @@ -1874,9 +1867,29 @@ public function update(Request $request, int $id): Response return ApiResponse::error('Failed to update server', 'SERVER_UPDATE_FAILED', 500); } - // Sync with Wings if node information is available - // If spell changed, trigger reinstall instead of just sync - if (isset($data['node_id']) || isset($data['allocation_id']) || isset($data['spell_id']) || isset($data['variables']) || isset($data['image']) || isset($data['startup']) || $mountIdsToSync !== null || $spellChanged) { + // Sync with Wings when build/configuration fields change (including resource limits). + // If spell changed, trigger reinstall instead of just sync. + $shouldSyncWithWings = isset($data['node_id']) + || isset($data['allocation_id']) + || isset($data['spell_id']) + || isset($data['variables']) + || isset($data['image']) + || isset($data['startup']) + || isset($data['memory']) + || isset($data['swap']) + || isset($data['disk']) + || isset($data['cpu']) + || isset($data['io']) + || array_key_exists('threads', $data) + || array_key_exists('oom_killer', $data) + || isset($data['allocation_limit']) + || isset($data['database_limit']) + || isset($data['backup_limit']) + || array_key_exists('backup_retention_mode', $data) + || $mountIdsToSync !== null + || $spellChanged; + + if ($shouldSyncWithWings) { $nodeInfo = Node::getNodeById($data['node_id'] ?? $server['node_id']); if ($nodeInfo) { $scheme = $nodeInfo['scheme']; diff --git a/backend/app/Controllers/Admin/SettingsController.php b/backend/app/Controllers/Admin/SettingsController.php index 61934b571..c078bcb72 100755 --- a/backend/app/Controllers/Admin/SettingsController.php +++ b/backend/app/Controllers/Admin/SettingsController.php @@ -173,7 +173,7 @@ class SettingsController ConfigInterface::APP_LOGO_DARK, ConfigInterface::APP_TIMEZONE, ConfigInterface::APP_SSO_REDIRECT_PATH, - ConfigInterface::APP_SUPPORT_URL, + ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES, ConfigInterface::APP_BACKGROUND_IMAGE_URL, ConfigInterface::APP_BACKGROUND_LOCK, ConfigInterface::APP_ACCENT_COLOR_DEFAULT, @@ -188,14 +188,25 @@ class SettingsController ConfigInterface::APP_BACKDROP_DARKEN_LOCK, ConfigInterface::APP_BACKGROUND_IMAGE_FIT_DEFAULT, ConfigInterface::APP_BACKGROUND_IMAGE_FIT_LOCK, + ], + ], + 'links' => [ + 'name' => 'Links', + 'description' => 'Public links shown across the panel footer (support, social media, legal)', + 'icon' => 'link', + 'settings' => [ + ConfigInterface::APP_SUPPORT_URL, + ConfigInterface::WEBSITE_URL, + ConfigInterface::DISCORD_URL, ConfigInterface::LINKEDIN_URL, ConfigInterface::TELEGRAM_URL, ConfigInterface::TIKTOK_URL, ConfigInterface::TWITTER_URL, ConfigInterface::WHATSAPP_URL, ConfigInterface::YOUTUBE_URL, - ConfigInterface::WEBSITE_URL, ConfigInterface::STATUS_PAGE_URL, + ConfigInterface::LEGAL_TOS, + ConfigInterface::LEGAL_PRIVACY, ], ], 'security' => [ @@ -204,6 +215,9 @@ class SettingsController 'icon' => 'shield', 'settings' => [ ConfigInterface::EMAIL_LOGIN_ENABLED, + ConfigInterface::LOGIN_DEFAULT_METHOD, + ConfigInterface::LOGIN_METHODS_ORDER, + ConfigInterface::LOGIN_HIDDEN_METHODS, ConfigInterface::CAPTCHA_PROVIDER, ConfigInterface::TURNSTILE_ENABLED, ConfigInterface::TURNSTILE_KEY_PUB, @@ -227,9 +241,13 @@ class SettingsController ConfigInterface::REGISTRATION_ENABLED, ConfigInterface::REGISTRATION_REQUIRE_EMAIL_VERIFICATION, + ConfigInterface::REGISTRATION_DEVICE_LIMIT_ENABLED, + ConfigInterface::REGISTRATION_DEVICE_MAX_ACCOUNTS, ConfigInterface::EMAIL_DOMAIN_BLOCKING_ENABLED, ConfigInterface::TELEMETRY, ConfigInterface::REQUIRE_TWO_FA_ADMINS, + ConfigInterface::AVATAR_PROVIDER, + ConfigInterface::AVATAR_CUSTOM_URL, ConfigInterface::USER_ALLOW_AVATAR_CHANGE, ConfigInterface::USER_ALLOW_USERNAME_CHANGE, ConfigInterface::USER_ALLOW_EMAIL_CHANGE, @@ -298,6 +316,7 @@ class SettingsController 'settings' => [ ConfigInterface::SERVER_ALLOW_EGG_CHANGE, ConfigInterface::SERVER_ALLOW_STARTUP_CHANGE, + ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE, ConfigInterface::SERVER_ALLOW_SUBUSERS, ConfigInterface::SERVER_ALLOW_SCHEDULES, ConfigInterface::SERVER_LIFECYCLE_HOOKS_ENABLED, @@ -384,8 +403,6 @@ class SettingsController 'description' => 'Other configuration settings', 'icon' => 'settings', 'settings' => [ - ConfigInterface::LEGAL_TOS, - ConfigInterface::LEGAL_PRIVACY, ConfigInterface::APP_DEVELOPER_MODE, ConfigInterface::CUSTOM_JS, ConfigInterface::CUSTOM_CSS, @@ -511,6 +528,19 @@ public function __construct() 'options' => [], 'category' => 'app', ], + ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES => [ + 'name' => ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES, '5'), + 'description' => 'Default lifetime in minutes for admin-generated SSO login tokens (1–1440)', + 'type' => 'number', + 'required' => true, + 'placeholder' => '5', + 'validation' => 'required|integer|min:1|max:1440', + 'options' => [], + 'category' => 'app', + ], ConfigInterface::APP_TIMEZONE => [ 'name' => ConfigInterface::APP_TIMEZONE, 'value' => $this->app @@ -746,7 +776,20 @@ public function __construct() 'placeholder' => 'https://mythical.systems', 'validation' => 'required|string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', + ], + ConfigInterface::DISCORD_URL => [ + 'name' => ConfigInterface::DISCORD_URL, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::DISCORD_URL, ''), + 'description' => 'Discord invite or community server URL', + 'type' => 'text', + 'required' => false, + 'placeholder' => 'https://discord.gg/example', + 'validation' => 'string|max:255', + 'options' => [], + 'category' => 'links', ], ConfigInterface::LINKEDIN_URL => [ 'name' => ConfigInterface::LINKEDIN_URL, @@ -759,7 +802,7 @@ public function __construct() 'placeholder' => 'https://linkedin.com/company/example', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::TELEGRAM_URL => [ 'name' => ConfigInterface::TELEGRAM_URL, @@ -772,7 +815,7 @@ public function __construct() 'placeholder' => 'https://t.me/example', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::TIKTOK_URL => [ 'name' => ConfigInterface::TIKTOK_URL, @@ -785,7 +828,7 @@ public function __construct() 'placeholder' => 'https://tiktok.com/@example', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::TWITTER_URL => [ 'name' => ConfigInterface::TWITTER_URL, @@ -798,7 +841,7 @@ public function __construct() 'placeholder' => 'https://twitter.com/example', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::WHATSAPP_URL => [ 'name' => ConfigInterface::WHATSAPP_URL, @@ -811,7 +854,7 @@ public function __construct() 'placeholder' => 'https://wa.me/1234567890', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::YOUTUBE_URL => [ 'name' => ConfigInterface::YOUTUBE_URL, @@ -824,7 +867,7 @@ public function __construct() 'placeholder' => 'https://youtube.com/@example', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::WEBSITE_URL => [ 'name' => ConfigInterface::WEBSITE_URL, @@ -837,7 +880,7 @@ public function __construct() 'placeholder' => 'https://example.com', 'validation' => 'string|max:255', 'options' => [], - 'category' => 'app', + 'category' => 'links', ], ConfigInterface::STATUS_PAGE_URL => [ 'name' => ConfigInterface::STATUS_PAGE_URL, @@ -1240,6 +1283,45 @@ public function __construct() 'options' => ['true', 'false'], 'category' => 'security', ], + ConfigInterface::LOGIN_DEFAULT_METHOD => [ + 'name' => ConfigInterface::LOGIN_DEFAULT_METHOD, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::LOGIN_DEFAULT_METHOD, 'local'), + 'description' => 'Default sign-in panel on the login page (local, ldap, email_code, discord, or oidc). Falls back to the first available method if the choice is hidden or unavailable.', + 'type' => 'select', + 'required' => true, + 'placeholder' => 'local', + 'validation' => 'required|string|max:64', + 'options' => ['local', 'ldap', 'email_code', 'discord', 'oidc'], + 'category' => 'security', + ], + ConfigInterface::LOGIN_METHODS_ORDER => [ + 'name' => ConfigInterface::LOGIN_METHODS_ORDER, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::LOGIN_METHODS_ORDER, 'local,passkey,ldap,email_code,discord,oidc'), + 'description' => 'Comma-separated order of login methods on the page. Valid ids: local, passkey, ldap, email_code, discord, oidc', + 'type' => 'text', + 'required' => true, + 'placeholder' => 'local,passkey,ldap,email_code,discord,oidc', + 'validation' => 'required|string|max:255', + 'options' => [], + 'category' => 'security', + ], + ConfigInterface::LOGIN_HIDDEN_METHODS => [ + 'name' => ConfigInterface::LOGIN_HIDDEN_METHODS, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::LOGIN_HIDDEN_METHODS, ''), + 'description' => 'Comma-separated login method ids to hide on the login page (e.g. passkey, local, ldap, email_code, discord, oidc). Leave empty to show all configured methods.', + 'type' => 'text', + 'required' => false, + 'placeholder' => 'passkey', + 'validation' => 'nullable|string|max:255', + 'options' => [], + 'category' => 'security', + ], ConfigInterface::LEGAL_TOS => [ 'name' => ConfigInterface::LEGAL_TOS, 'value' => $this->app @@ -1251,7 +1333,7 @@ public function __construct() 'placeholder' => '/tos', 'validation' => 'required|string|max:255', 'options' => [], - 'category' => 'other', + 'category' => 'links', ], ConfigInterface::LEGAL_PRIVACY => [ 'name' => ConfigInterface::LEGAL_PRIVACY, @@ -1264,7 +1346,7 @@ public function __construct() 'placeholder' => '/privacy', 'validation' => 'required|string|max:255', 'options' => [], - 'category' => 'other', + 'category' => 'links', ], ConfigInterface::REGISTRATION_ENABLED => [ 'name' => ConfigInterface::REGISTRATION_ENABLED, @@ -1295,6 +1377,32 @@ public function __construct() 'options' => ['true', 'false'], 'category' => 'security', ], + ConfigInterface::REGISTRATION_DEVICE_LIMIT_ENABLED => [ + 'name' => ConfigInterface::REGISTRATION_DEVICE_LIMIT_ENABLED, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::REGISTRATION_DEVICE_LIMIT_ENABLED, 'false'), + 'description' => 'Block new registrations when a browser/device already has the maximum number of panel accounts.', + 'type' => 'select', + 'required' => true, + 'placeholder' => 'false', + 'validation' => 'required|string|max:255', + 'options' => ['true', 'false'], + 'category' => 'security', + ], + ConfigInterface::REGISTRATION_DEVICE_MAX_ACCOUNTS => [ + 'name' => ConfigInterface::REGISTRATION_DEVICE_MAX_ACCOUNTS, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::REGISTRATION_DEVICE_MAX_ACCOUNTS, '1'), + 'description' => 'Maximum number of accounts allowed per browser/device before registration is blocked (main account is the oldest account seen on that device).', + 'type' => 'number', + 'required' => true, + 'placeholder' => '1', + 'validation' => 'required|integer|min:1|max:10', + 'options' => [], + 'category' => 'security', + ], ConfigInterface::EMAIL_DOMAIN_BLOCKING_ENABLED => [ 'name' => ConfigInterface::EMAIL_DOMAIN_BLOCKING_ENABLED, 'value' => $this->app @@ -1324,6 +1432,38 @@ public function __construct() 'options' => ['true', 'false'], 'category' => 'app', ], + ConfigInterface::AVATAR_PROVIDER => [ + 'name' => ConfigInterface::AVATAR_PROVIDER, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::AVATAR_PROVIDER, 'gravatar'), + 'description' => 'Default avatar provider for users without a custom profile picture', + 'type' => 'select', + 'required' => true, + 'placeholder' => 'gravatar', + 'validation' => 'required|string|max:255', + 'options' => [ + 'gravatar', + 'panel_logo', + 'ui_avatars', + 'robohash', + 'dicebear', + 'custom', + ], + 'category' => 'security', + ], + ConfigInterface::AVATAR_CUSTOM_URL => [ + 'name' => ConfigInterface::AVATAR_CUSTOM_URL, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::AVATAR_CUSTOM_URL, ''), + 'description' => 'Custom avatar URL template (only used when avatar provider is custom). Placeholders: {email}, {username}, {name}, {hash}, {app_url}', + 'type' => 'text', + 'required' => false, + 'placeholder' => 'https://example.com/avatar/{hash}', + 'validation' => 'nullable|string|max:2048', + 'category' => 'security', + ], ConfigInterface::USER_ALLOW_AVATAR_CHANGE => [ 'name' => ConfigInterface::USER_ALLOW_AVATAR_CHANGE, 'value' => $this->app @@ -1565,6 +1705,22 @@ public function __construct() 'description' => 'Allow users to change the server startup', 'category' => 'servers', ], + ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE => [ + 'name' => ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE, + 'value' => $this->app + ->getConfig() + ->getSetting( + ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE, + 'false', + ), + 'type' => 'select', + 'required' => true, + 'placeholder' => 'false', + 'validation' => 'required|string|max:255', + 'options' => ['true', 'false'], + 'description' => 'Allow users to enter a custom Docker image on the startup page. When disabled, users may only select images configured on the spell.', + 'category' => 'servers', + ], ConfigInterface::SERVER_ALLOW_SCHEDULES => [ 'name' => ConfigInterface::SERVER_ALLOW_SCHEDULES, 'value' => $this->app diff --git a/backend/app/Controllers/Admin/SpellsController.php b/backend/app/Controllers/Admin/SpellsController.php index 57ca07811..3b109fe66 100755 --- a/backend/app/Controllers/Admin/SpellsController.php +++ b/backend/app/Controllers/Admin/SpellsController.php @@ -41,6 +41,7 @@ new OA\Property(property: 'description', type: 'string', nullable: true, description: 'Spell description'), new OA\Property(property: 'features', type: 'string', nullable: true, description: 'Spell features (JSON)'), new OA\Property(property: 'docker_images', type: 'string', nullable: true, description: 'Docker images (JSON)'), + new OA\Property(property: 'default_docker_image', type: 'string', nullable: true, description: 'Default Docker image used on new server installs'), new OA\Property(property: 'file_denylist', type: 'string', nullable: true, description: 'File denylist (JSON)'), new OA\Property(property: 'update_url', type: 'string', nullable: true, description: 'Update URL'), new OA\Property(property: 'config_files', type: 'string', nullable: true, description: 'Config files'), @@ -84,6 +85,7 @@ new OA\Property(property: 'description', type: 'string', nullable: true, description: 'Spell description'), new OA\Property(property: 'features', type: 'string', nullable: true, description: 'Spell features (JSON)'), new OA\Property(property: 'docker_images', type: 'string', nullable: true, description: 'Docker images (JSON)'), + new OA\Property(property: 'default_docker_image', type: 'string', nullable: true, description: 'Default Docker image used on new server installs'), new OA\Property(property: 'file_denylist', type: 'string', nullable: true, description: 'File denylist (JSON)'), new OA\Property(property: 'update_url', type: 'string', nullable: true, description: 'Update URL'), new OA\Property(property: 'config_files', type: 'string', nullable: true, description: 'Config files'), @@ -112,6 +114,7 @@ new OA\Property(property: 'description', type: 'string', nullable: true, description: 'Spell description'), new OA\Property(property: 'features', type: 'string', nullable: true, description: 'Spell features (JSON)'), new OA\Property(property: 'docker_images', type: 'string', nullable: true, description: 'Docker images (JSON)'), + new OA\Property(property: 'default_docker_image', type: 'string', nullable: true, description: 'Default Docker image used on new server installs'), new OA\Property(property: 'file_denylist', type: 'string', nullable: true, description: 'File denylist (JSON)'), new OA\Property(property: 'update_url', type: 'string', nullable: true, description: 'Update URL'), new OA\Property(property: 'config_files', type: 'string', nullable: true, description: 'Config files'), @@ -415,7 +418,7 @@ public function create(Request $request): Response } // Validate string fields - $stringFields = ['author', 'name', 'description', 'update_url', 'config_files', 'config_startup', 'config_logs', 'config_stop', 'startup', 'script_container', 'script_entry', 'script_install']; + $stringFields = ['author', 'name', 'description', 'update_url', 'config_files', 'config_startup', 'config_logs', 'config_stop', 'startup', 'script_container', 'script_entry', 'script_install', 'default_docker_image']; foreach ($stringFields as $field) { if (isset($data[$field]) && !is_string($data[$field])) { return ApiResponse::error("$field must be a string", 'INVALID_DATA_TYPE'); @@ -432,6 +435,18 @@ public function create(Request $request): Response } } + if (isset($data['default_docker_image']) && $data['default_docker_image'] !== null && !is_string($data['default_docker_image'])) { + return ApiResponse::error('default_docker_image must be a string', 'INVALID_DATA_TYPE', 400); + } + + $dockerImagesJson = $data['docker_images'] ?? null; + if (array_key_exists('default_docker_image', $data) || !empty($dockerImagesJson)) { + $data['default_docker_image'] = Spell::sanitizeDefaultDockerImage( + $data['default_docker_image'] ?? null, + $dockerImagesJson + ); + } + // Validate boolean fields $booleanFields = ['script_is_privileged', 'force_outgoing_ip']; foreach ($booleanFields as $field) { @@ -571,7 +586,7 @@ public function update(Request $request, int $id): Response } // Validate string fields - $stringFields = ['author', 'name', 'description', 'update_url', 'config_files', 'config_startup', 'config_logs', 'config_stop', 'startup', 'script_container', 'script_entry', 'script_install']; + $stringFields = ['author', 'name', 'description', 'update_url', 'config_files', 'config_startup', 'config_logs', 'config_stop', 'startup', 'script_container', 'script_entry', 'script_install', 'default_docker_image']; foreach ($stringFields as $field) { if (isset($data[$field]) && !is_string($data[$field])) { return ApiResponse::error("$field must be a string", 'INVALID_DATA_TYPE'); @@ -588,6 +603,18 @@ public function update(Request $request, int $id): Response } } + if (isset($data['default_docker_image']) && $data['default_docker_image'] !== null && !is_string($data['default_docker_image'])) { + return ApiResponse::error('default_docker_image must be a string', 'INVALID_DATA_TYPE', 400); + } + + $dockerImagesJson = $data['docker_images'] ?? $spell['docker_images'] ?? null; + if (array_key_exists('default_docker_image', $data) || array_key_exists('docker_images', $data)) { + $data['default_docker_image'] = Spell::sanitizeDefaultDockerImage( + $data['default_docker_image'] ?? $spell['default_docker_image'] ?? null, + $dockerImagesJson + ); + } + // Validate boolean fields $booleanFields = ['script_is_privileged', 'force_outgoing_ip']; foreach ($booleanFields as $field) { @@ -995,33 +1022,14 @@ public function import(Request $request): Response return ApiResponse::error('Invalid JSON format', 'INVALID_JSON', 400); } + $admin = $request->attributes->get('user'); + // Map JSON data to spell format - $spellData = [ - 'realm_id' => (int) $realmId, - 'uuid' => Spell::generateUuid(), - 'name' => $jsonData['name'] ?? 'Imported Spell', - 'author' => $jsonData['author'] ?? 'Unknown', - 'description' => $jsonData['description'] ?? '', - 'features' => isset($jsonData['features']) && $jsonData['features'] !== null ? json_encode($jsonData['features']) : null, - 'docker_images' => isset($jsonData['docker_images']) && $jsonData['docker_images'] !== null ? json_encode($jsonData['docker_images']) : null, - 'file_denylist' => isset($jsonData['file_denylist']) && $jsonData['file_denylist'] !== null ? json_encode($jsonData['file_denylist']) : null, - 'update_url' => $jsonData['meta']['update_url'] ?? null, - 'config_files' => $jsonData['config']['files'] ?? null, - 'config_startup' => $jsonData['config']['startup'] ?? null, - 'config_logs' => $jsonData['config']['logs'] ?? null, - 'config_stop' => $jsonData['config']['stop'] ?? null, - 'startup' => $jsonData['startup'] ?? null, - 'script_container' => $jsonData['scripts']['installation']['container'] ?? 'alpine:3.4', - 'script_entry' => $jsonData['scripts']['installation']['entrypoint'] ?? 'ash', - 'script_is_privileged' => true, - 'script_install' => $jsonData['scripts']['installation']['script'] ?? null, - 'force_outgoing_ip' => false, - ]; + $spellData = self::mapImportJsonToSpellData($jsonData, (int) $realmId); - // Preserve original UUID if it exists in FeatherPanel metadata - if (isset($jsonData['_featherpanel']['spell_metadata']['uuid'])) { - $originalUuid = $jsonData['_featherpanel']['spell_metadata']['uuid']; - // Check if UUID already exists + // Preserve original UUID if it exists in FeatherPanel metadata or legacy raw exports + $originalUuid = $jsonData['_featherpanel']['spell_metadata']['uuid'] ?? $jsonData['uuid'] ?? null; + if (is_string($originalUuid) && $originalUuid !== '') { $existingSpell = Spell::getSpellByUuid($originalUuid); if (!$existingSpell) { $spellData['uuid'] = $originalUuid; @@ -1117,8 +1125,6 @@ public function import(Request $request): Response $logContext .= ' (originally exported by: ' . $importMetadata['original_export_info']['exported_by'] . ')'; } - // Log activity - $admin = $request->attributes->get('user'); Activity::createActivity([ 'user_uuid' => $admin['uuid'] ?? null, 'name' => 'import_spell', @@ -1170,6 +1176,8 @@ public function import(Request $request): Response )] public function export(Request $request, int $id): Response { + $admin = $request->attributes->get('user'); + // Get spell with realm information $spell = Spell::getSpellWithRealm($id); if (!$spell) { @@ -1179,9 +1187,13 @@ public function export(Request $request, int $id): Response // Get spell variables $variables = SpellVariable::getVariablesBySpellId($id); + $features = self::decodeJsonField($spell['features'] ?? null, []); + $dockerImages = self::decodeJsonField($spell['docker_images'] ?? null, []); + $fileDenylist = self::decodeJsonField($spell['file_denylist'] ?? null, []); + // Build export data structure matching the import format $exportData = [ - '_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL', + '_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY FEATHERPANEL', 'meta' => [ 'update_url' => $spell['update_url'], 'version' => 'PTDL_v2', @@ -1190,15 +1202,17 @@ public function export(Request $request, int $id): Response 'name' => $spell['name'], 'author' => $spell['author'], 'description' => $spell['description'], - 'features' => !empty($spell['features']) ? json_decode($spell['features'], true) : [], - 'docker_images' => !empty($spell['docker_images']) ? json_decode($spell['docker_images'], true) : [], - 'file_denylist' => !empty($spell['file_denylist']) ? json_decode($spell['file_denylist'], true) : [], + 'features' => is_array($features) ? $features : [], + 'docker_images' => is_array($dockerImages) ? $dockerImages : [], + 'file_denylist' => is_array($fileDenylist) ? $fileDenylist : [], 'startup' => $spell['startup'], 'config' => [ - 'files' => $spell['config_files'] ?? '{}', - 'startup' => $spell['config_startup'] ?? '{}', - 'logs' => $spell['config_logs'] ?? '{}', - 'stop' => $spell['config_stop'] ?? 'stop', + 'files' => self::decodeSpellConfigField($spell['config_files'] ?? null, (object) []), + 'startup' => self::decodeSpellConfigField($spell['config_startup'] ?? null, (object) []), + 'logs' => self::decodeSpellConfigField($spell['config_logs'] ?? null, (object) []), + 'stop' => is_string($spell['config_stop'] ?? null) && trim($spell['config_stop']) !== '' + ? $spell['config_stop'] + : 'stop', ], 'scripts' => [ 'installation' => [ @@ -1226,10 +1240,11 @@ public function export(Request $request, int $id): Response 'force_outgoing_ip' => (bool) $spell['force_outgoing_ip'], 'config_from' => $spell['config_from'], 'copy_script_from' => $spell['copy_script_from'], + 'default_docker_image' => Spell::resolveDefaultDockerImage($spell), ], 'variables_count' => count($variables), - 'features_count' => !empty($spell['features']) ? count(json_decode($spell['features'], true)) : 0, - 'docker_images_count' => !empty($spell['docker_images']) ? count(json_decode($spell['docker_images'], true)) : 0, + 'features_count' => is_array($features) ? count($features) : 0, + 'docker_images_count' => is_array($dockerImages) ? count($dockerImages) : 0, ], ]; @@ -1250,8 +1265,6 @@ public function export(Request $request, int $id): Response // Generate filename $filename = strtolower(str_replace(' ', '-', $spell['name'])) . '.json'; - // Log activity - $admin = $request->attributes->get('user'); Activity::createActivity([ 'user_uuid' => $admin['uuid'] ?? null, 'name' => 'export_spell', @@ -1767,32 +1780,15 @@ public function onlineInstall(Request $request): Response } // Map JSON data to spell format - $spellData = [ - 'realm_id' => (int) $realmId, - 'uuid' => Spell::generateUuid(), + $spellData = self::mapImportJsonToSpellData($jsonData, (int) $realmId, [ 'name' => $jsonData['name'] ?? $match['display_name'] ?? 'Imported Spell', 'author' => $jsonData['author'] ?? $match['author'] ?? 'Unknown', 'description' => $jsonData['description'] ?? $match['description'] ?? '', - 'features' => isset($jsonData['features']) && $jsonData['features'] !== null ? json_encode($jsonData['features']) : null, - 'docker_images' => isset($jsonData['docker_images']) && $jsonData['docker_images'] !== null ? json_encode($jsonData['docker_images']) : null, - 'file_denylist' => isset($jsonData['file_denylist']) && $jsonData['file_denylist'] !== null ? json_encode($jsonData['file_denylist']) : null, - 'update_url' => $jsonData['meta']['update_url'] ?? null, - 'config_files' => $jsonData['config']['files'] ?? null, - 'config_startup' => $jsonData['config']['startup'] ?? null, - 'config_logs' => $jsonData['config']['logs'] ?? null, - 'config_stop' => $jsonData['config']['stop'] ?? null, - 'startup' => $jsonData['startup'] ?? null, - 'script_container' => $jsonData['scripts']['installation']['container'] ?? 'alpine:3.4', - 'script_entry' => $jsonData['scripts']['installation']['entrypoint'] ?? 'ash', - 'script_is_privileged' => true, - 'script_install' => $jsonData['scripts']['installation']['script'] ?? null, - 'force_outgoing_ip' => false, - ]; + ]); - // Preserve original UUID if it exists in FeatherPanel metadata - if (isset($jsonData['_featherpanel']['spell_metadata']['uuid'])) { - $originalUuid = $jsonData['_featherpanel']['spell_metadata']['uuid']; - // Check if UUID already exists + // Preserve original UUID if it exists in FeatherPanel metadata or legacy raw exports + $originalUuid = $jsonData['_featherpanel']['spell_metadata']['uuid'] ?? $jsonData['uuid'] ?? null; + if (is_string($originalUuid) && $originalUuid !== '') { $existingSpell = Spell::getSpellByUuid($originalUuid); if (!$existingSpell) { $spellData['uuid'] = $originalUuid; @@ -1881,4 +1877,156 @@ public function onlineInstall(Request $request): Response return ApiResponse::error('Failed to install spell from online repository: ' . $e->getMessage(), 500); } } + + /** + * Decode a spell config field stored as JSON text for PTDL export. + */ + private static function decodeSpellConfigField(?string $value, mixed $default): mixed + { + if ($value === null || trim($value) === '') { + return $default; + } + + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded ?? $default; + } + + return $value; + } + + /** + * Decode a JSON field that may already be decoded or stored as a JSON string. + */ + private static function decodeJsonField(mixed $value, mixed $default): mixed + { + if ($value === null || $value === '') { + return $default; + } + + if (is_array($value)) { + return $value; + } + + if (!is_string($value)) { + return $default; + } + + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded ?? $default; + } + + return $default; + } + + /** + * Normalize imported config values for database storage. + */ + private static function encodeSpellConfigFieldForStorage(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return json_encode($value); + } + + if (is_string($value)) { + return trim($value) === '' ? null : $value; + } + + return (string) $value; + } + + /** + * Normalize legacy raw API spell exports into PTDL_v2 import format. + */ + private static function normalizeSpellImportJson(array $jsonData): array + { + if (isset($jsonData['meta']['version']) || isset($jsonData['config']) || isset($jsonData['scripts'])) { + return $jsonData; + } + + $metadata = array_filter([ + 'uuid' => $jsonData['uuid'] ?? null, + 'script_is_privileged' => isset($jsonData['script_is_privileged']) ? (bool) $jsonData['script_is_privileged'] : null, + 'force_outgoing_ip' => isset($jsonData['force_outgoing_ip']) ? (bool) $jsonData['force_outgoing_ip'] : null, + 'config_from' => $jsonData['config_from'] ?? null, + 'copy_script_from' => $jsonData['copy_script_from'] ?? null, + 'default_docker_image' => $jsonData['default_docker_image'] ?? null, + ], static fn ($value) => $value !== null); + + return [ + 'meta' => [ + 'update_url' => $jsonData['update_url'] ?? null, + 'version' => 'PTDL_v2', + ], + 'name' => $jsonData['name'] ?? 'Imported Spell', + 'author' => $jsonData['author'] ?? 'Unknown', + 'description' => $jsonData['description'] ?? '', + 'features' => self::decodeJsonField($jsonData['features'] ?? null, []), + 'docker_images' => self::decodeJsonField($jsonData['docker_images'] ?? null, []), + 'file_denylist' => self::decodeJsonField($jsonData['file_denylist'] ?? null, []), + 'startup' => $jsonData['startup'] ?? null, + 'config' => [ + 'files' => self::decodeJsonField($jsonData['config_files'] ?? null, (object) []), + 'startup' => self::decodeJsonField($jsonData['config_startup'] ?? null, (object) []), + 'logs' => self::decodeJsonField($jsonData['config_logs'] ?? null, (object) []), + 'stop' => is_string($jsonData['config_stop'] ?? null) && trim($jsonData['config_stop']) !== '' + ? $jsonData['config_stop'] + : 'stop', + ], + 'scripts' => [ + 'installation' => [ + 'container' => $jsonData['script_container'] ?? 'alpine:3.4', + 'entrypoint' => $jsonData['script_entry'] ?? 'ash', + 'script' => $jsonData['script_install'] ?? '', + ], + ], + 'variables' => is_array($jsonData['variables'] ?? null) ? $jsonData['variables'] : [], + '_featherpanel' => $metadata === [] ? [] : ['spell_metadata' => $metadata], + ]; + } + + /** + * Map imported JSON (PTDL_v2 or legacy raw export) to spell database fields. + */ + private static function mapImportJsonToSpellData(array $jsonData, int $realmId, array $overrides = []): array + { + $jsonData = self::normalizeSpellImportJson($jsonData); + $metadata = $jsonData['_featherpanel']['spell_metadata'] ?? []; + + $spellData = [ + 'realm_id' => $realmId, + 'uuid' => Spell::generateUuid(), + 'name' => $overrides['name'] ?? $jsonData['name'] ?? 'Imported Spell', + 'author' => $overrides['author'] ?? $jsonData['author'] ?? 'Unknown', + 'description' => $overrides['description'] ?? $jsonData['description'] ?? '', + 'features' => isset($jsonData['features']) && $jsonData['features'] !== null ? json_encode($jsonData['features']) : null, + 'docker_images' => isset($jsonData['docker_images']) && $jsonData['docker_images'] !== null ? json_encode($jsonData['docker_images']) : null, + 'file_denylist' => isset($jsonData['file_denylist']) && $jsonData['file_denylist'] !== null ? json_encode($jsonData['file_denylist']) : null, + 'update_url' => $jsonData['meta']['update_url'] ?? null, + 'config_files' => self::encodeSpellConfigFieldForStorage($jsonData['config']['files'] ?? null), + 'config_startup' => self::encodeSpellConfigFieldForStorage($jsonData['config']['startup'] ?? null), + 'config_logs' => self::encodeSpellConfigFieldForStorage($jsonData['config']['logs'] ?? null), + 'config_stop' => is_string($jsonData['config']['stop'] ?? null) && trim($jsonData['config']['stop']) !== '' + ? $jsonData['config']['stop'] + : 'stop', + 'startup' => $jsonData['startup'] ?? null, + 'script_container' => $jsonData['scripts']['installation']['container'] ?? 'alpine:3.4', + 'script_entry' => $jsonData['scripts']['installation']['entrypoint'] ?? 'ash', + 'script_is_privileged' => $metadata['script_is_privileged'] ?? true, + 'script_install' => $jsonData['scripts']['installation']['script'] ?? null, + 'force_outgoing_ip' => $metadata['force_outgoing_ip'] ?? false, + ]; + + $spellData['default_docker_image'] = Spell::sanitizeDefaultDockerImage( + is_string($metadata['default_docker_image'] ?? null) ? $metadata['default_docker_image'] : null, + $spellData['docker_images'] + ); + + return $spellData; + } } diff --git a/backend/app/Controllers/Admin/TicketMessagesController.php b/backend/app/Controllers/Admin/TicketMessagesController.php index 91ce72671..0a136744f 100755 --- a/backend/app/Controllers/Admin/TicketMessagesController.php +++ b/backend/app/Controllers/Admin/TicketMessagesController.php @@ -27,6 +27,7 @@ use App\Plugins\Events\Events\TicketEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use App\Services\Tickets\TicketNotificationService; #[OA\Schema( schema: 'TicketMessage', @@ -293,6 +294,10 @@ public function create(Request $request, string $uuid): Response ); } + if ($createdMessage) { + TicketNotificationService::notifyReply($ticket, $createdMessage, $currentUser['uuid'] ?? null); + } + return ApiResponse::success(['message_id' => $messageId], 'Message created successfully', 201); } diff --git a/backend/app/Controllers/Admin/TicketsController.php b/backend/app/Controllers/Admin/TicketsController.php index 6cd65cdc8..dba039e94 100755 --- a/backend/app/Controllers/Admin/TicketsController.php +++ b/backend/app/Controllers/Admin/TicketsController.php @@ -34,6 +34,7 @@ use App\Plugins\Events\Events\TicketEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use App\Services\Tickets\TicketNotificationService; #[OA\Schema( schema: 'Ticket', @@ -386,6 +387,7 @@ public function show(Request $request, string $uuid): Response $rolesMap[$role['id']] = [ 'name' => $role['name'], 'display_name' => $role['display_name'], + 'custom_badge' => $role['custom_badge'] ?? null, 'color' => $role['color'], ]; } @@ -708,6 +710,7 @@ public function update(Request $request, string $uuid): Response 'user_uuid' => $currentUser['uuid'], ] ); + TicketNotificationService::notifyClosed($updatedTicket); } elseif ($oldStatusName === 'closed' && $newStatusName === 'open') { $eventManager->emit( TicketEvent::onTicketReopened(), @@ -716,6 +719,7 @@ public function update(Request $request, string $uuid): Response 'user_uuid' => $currentUser['uuid'], ] ); + TicketNotificationService::notifyReopened($updatedTicket); } } } @@ -878,6 +882,8 @@ public function close(Request $request, string $uuid): Response ); } + TicketNotificationService::notifyClosed($updatedTicket); + return ApiResponse::success([], 'Ticket closed successfully', 200); } @@ -963,6 +969,8 @@ public function reopen(Request $request, string $uuid): Response ); } + TicketNotificationService::notifyReopened($updatedTicket); + return ApiResponse::success([], 'Ticket reopened successfully', 200); } @@ -1073,6 +1081,10 @@ public function reply(Request $request, string $uuid): Response ); } + if ($message !== null) { + TicketNotificationService::notifyReply($ticket, $message, $currentUser['uuid'] ?? null); + } + return ApiResponse::success([ 'message_id' => $messageId, ], 'Reply added successfully', 201); diff --git a/backend/app/Controllers/Admin/UsersController.php b/backend/app/Controllers/Admin/UsersController.php index 878bfdea2..4e41cc7f4 100755 --- a/backend/app/Controllers/Admin/UsersController.php +++ b/backend/app/Controllers/Admin/UsersController.php @@ -221,6 +221,20 @@ class UsersController required: false, schema: new OA\Schema(type: 'string') ), + new OA\Parameter( + name: 'ip', + in: 'query', + description: 'Filter users by first or last IP address (partial match)', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'email_verified', + in: 'query', + description: 'Filter users by email verification status (true/false)', + required: false, + schema: new OA\Schema(type: 'string') + ), new OA\Parameter( name: 'sort_by', in: 'query', @@ -266,6 +280,8 @@ public function index(Request $request): Response $userId = $request->query->getInt('user_id', 0) ?: null; $uuid = $request->query->get('uuid'); $externalId = $request->query->get('external_id'); + $ip = trim((string) $request->query->get('ip', '')); + $emailVerifiedParam = $request->query->get('email_verified'); $sortBy = $request->query->get('sort_by', 'id'); $sortOrder = strtoupper((string) $request->query->get('sort_order', 'ASC')); @@ -278,6 +294,15 @@ public function index(Request $request): Response } } + $emailVerified = null; + if ($emailVerifiedParam !== null && $emailVerifiedParam !== '') { + if ($emailVerifiedParam === 'true' || $emailVerifiedParam === '1') { + $emailVerified = true; + } elseif ($emailVerifiedParam === 'false' || $emailVerifiedParam === '0') { + $emailVerified = false; + } + } + $allowedSortFields = ['id', 'username', 'email', 'last_seen', 'created_at']; if (!in_array($sortBy, $allowedSortFields, true)) { $sortBy = 'id'; @@ -287,6 +312,9 @@ public function index(Request $request): Response $sortOrder = 'ASC'; } + // Users table stores account creation time in first_seen, not created_at. + $sortByColumn = $sortBy === 'created_at' ? 'first_seen' : $sortBy; + if ($page < 1) { $page = 1; } @@ -309,20 +337,25 @@ public function index(Request $request): Response 'role_id', 'avatar', 'last_seen', + 'first_seen', 'email', 'mail_verify', 'oidc_provider', 'oidc_subject', 'ldap_provider_uuid', 'ldap_dn', + 'first_ip', + 'last_ip', ], - $sortBy, + $sortByColumn, $sortOrder, $roleId, $banned, $userId, $uuid ?: null, $externalId ?: null, + $ip !== '' ? $ip : null, + $emailVerified, ); $roles = \App\Chat\Role::getAllRoles(); @@ -331,6 +364,7 @@ public function index(Request $request): Response $rolesMap[$role['id']] = [ 'name' => $role['name'], 'display_name' => $role['display_name'], + 'custom_badge' => $role['custom_badge'] ?? null, 'color' => $role['color'], ]; } @@ -340,13 +374,16 @@ public function index(Request $request): Response if (isset($rolesMap[$userRoleId])) { $user['role']['name'] = $rolesMap[$userRoleId]['name']; $user['role']['display_name'] = $rolesMap[$userRoleId]['display_name']; + $user['role']['custom_badge'] = $rolesMap[$userRoleId]['custom_badge']; $user['role']['color'] = $rolesMap[$userRoleId]['color']; } else { $user['role']['name'] = $userRoleId; $user['role']['display_name'] = 'User'; + $user['role']['custom_badge'] = null; $user['role']['color'] = '#666666'; } $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; + $user['created_at'] = $user['first_seen'] ?? null; if ($app->isDemoMode()) { $user['first_ip'] = $app->getIPIntoFBIFormat(); $user['last_ip'] = $app->getIPIntoFBIFormat(); @@ -364,6 +401,8 @@ public function index(Request $request): Response $userId, $uuid ?: null, $externalId ?: null, + $ip !== '' ? $ip : null, + $emailVerified, ); $totalPages = ceil($total / $limit); $from = ($page - 1) * $limit + 1; @@ -433,6 +472,7 @@ public function show(Request $request, string $uuid): Response $rolesMap[$role['id']] = [ 'name' => $role['name'], 'display_name' => $role['display_name'], + 'custom_badge' => $role['custom_badge'] ?? null, 'color' => $role['color'], ]; } @@ -440,6 +480,7 @@ public function show(Request $request, string $uuid): Response $user['role'] = [ 'name' => $rolesMap[$roleId]['name'] ?? $roleId, 'display_name' => $rolesMap[$roleId]['display_name'] ?? 'User', + 'custom_badge' => $rolesMap[$roleId]['custom_badge'] ?? null, 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; @@ -460,12 +501,15 @@ public function show(Request $request, string $uuid): Response $queueIds = array_column($mailList, 'queue_id'); $mailQueues = MailQueue::getByIds($queueIds); $user['mails'] = []; - foreach ($queueIds as $queueId) { - if (isset($mailQueues[$queueId])) { - $mail = $mailQueues[$queueId]; - unset($mail['id'], $mail['user_uuid'], $mail['deleted'], $mail['locked'], $mail['updated_at']); - $user['mails'][] = $mail; + foreach ($mailList as $mailListEntry) { + $queueId = (int) ($mailListEntry['queue_id'] ?? 0); + if (!isset($mailQueues[$queueId])) { + continue; } + $mail = $mailQueues[$queueId]; + $mail['id'] = (int) $mailListEntry['id']; + unset($mail['user_uuid'], $mail['deleted'], $mail['locked'], $mail['updated_at']); + $user['mails'][] = $mail; } if ($app->isDemoMode()) { $user['first_ip'] = $app->getIPIntoFBIFormat(); @@ -527,6 +571,7 @@ public function showByExternalId(Request $request, string $externalId): Response $rolesMap[$role['id']] = [ 'name' => $role['name'], 'display_name' => $role['display_name'], + 'custom_badge' => $role['custom_badge'] ?? null, 'color' => $role['color'], ]; } @@ -534,6 +579,7 @@ public function showByExternalId(Request $request, string $externalId): Response $user['role'] = [ 'name' => $rolesMap[$roleId]['name'] ?? $roleId, 'display_name' => $rolesMap[$roleId]['display_name'] ?? 'User', + 'custom_badge' => $rolesMap[$roleId]['custom_badge'] ?? null, 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; @@ -554,12 +600,15 @@ public function showByExternalId(Request $request, string $externalId): Response $queueIds = array_column($mailList, 'queue_id'); $mailQueues = MailQueue::getByIds($queueIds); $user['mails'] = []; - foreach ($queueIds as $queueId) { - if (isset($mailQueues[$queueId])) { - $mail = $mailQueues[$queueId]; - unset($mail['id'], $mail['user_uuid'], $mail['deleted'], $mail['locked'], $mail['updated_at']); - $user['mails'][] = $mail; + foreach ($mailList as $mailListEntry) { + $queueId = (int) ($mailListEntry['queue_id'] ?? 0); + if (!isset($mailQueues[$queueId])) { + continue; } + $mail = $mailQueues[$queueId]; + $mail['id'] = (int) $mailListEntry['id']; + unset($mail['user_uuid'], $mail['deleted'], $mail['locked'], $mail['updated_at']); + $user['mails'][] = $mail; } if ($app->isDemoMode()) { @@ -659,12 +708,7 @@ public function create(Request $request): Response // Generate UUID $data['uuid'] = UUIDUtils::generateV4(); $config = App::getInstance(true)->getConfig(); - $avatar = $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png'); $data['remember_token'] = User::generateAccountToken(); - // Set default avatar if not provided - if (empty($data['avatar'])) { - $data['avatar'] = $avatar; - } // Set default role if not provided if (empty($data['role_id'])) { $data['role_id'] = 1; @@ -1036,6 +1080,7 @@ public function delete(Request $request, string $uuid): Response } Activity::deleteUserData($user['uuid']); + \App\Chat\UserDevice::deleteUserData($user['uuid']); MailList::deleteAllMailListsByUserId($user['uuid']); ApiClient::deleteAllApiClientsByUserId($user['uuid']); Subuser::deleteAllSubusersByUserId((int) $user['id']); @@ -1250,6 +1295,163 @@ public function ownedVmInstances(Request $request, string $uuid): Response ], 'Owned VM instances fetched', 200); } + #[OA\Get( + path: '/api/admin/users/{uuid}/potential-alts', + summary: 'Find potential alt accounts by IP', + description: 'Find other users who may be alternate accounts by comparing IP addresses from activity logs and first/last IP fields.', + tags: ['Admin - Users'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'User UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Potential alt accounts retrieved successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'potential_alts', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'source_ips', type: 'array', items: new OA\Items(type: 'string')), + new OA\Property(property: 'total', type: 'integer'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'User not found'), + ] + )] + public function potentialAlts(Request $request, string $uuid): Response + { + $app = App::getInstance(true); + $user = User::getUserByUuid($uuid); + if (!$user) { + return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); + } + + $result = User::findPotentialAltsByUuid($uuid); + $roles = \App\Chat\Role::getAllRoles(); + $rolesMap = []; + foreach ($roles as $role) { + $rolesMap[$role['id']] = [ + 'name' => $role['name'], + 'display_name' => $role['display_name'], + 'custom_badge' => $role['custom_badge'] ?? null, + 'color' => $role['color'], + ]; + } + + $sourceIps = $result['source_ips']; + $sourceDevices = $result['source_devices']; + if ($app->isDemoMode()) { + $sourceIps = array_map(static fn () => $app->getIPIntoFBIFormat(), $sourceIps); + $sourceDevices = array_map(static fn ($hash) => substr(hash('sha256', (string) $hash), 0, 16), $sourceDevices); + } + + $potentialAlts = []; + foreach ($result['potential_alts'] as $alt) { + $roleId = $alt['role_id'] ?? null; + $alt['role'] = [ + 'name' => $rolesMap[$roleId]['name'] ?? $roleId, + 'display_name' => $rolesMap[$roleId]['display_name'] ?? 'User', + 'custom_badge' => $rolesMap[$roleId]['custom_badge'] ?? null, + 'color' => $rolesMap[$roleId]['color'] ?? '#666666', + ]; + unset($alt['role_id']); + + if ($app->isDemoMode()) { + $alt['first_ip'] = $app->getIPIntoFBIFormat(); + $alt['last_ip'] = $app->getIPIntoFBIFormat(); + $alt['shared_ips'] = array_map(static fn () => $app->getIPIntoFBIFormat(), $alt['shared_ips']); + $alt['shared_devices'] = array_map(static fn () => substr(hash('sha256', uniqid('', true)), 0, 16), $alt['shared_devices']); + } else { + $alt['shared_devices'] = array_map(static fn ($hash) => substr((string) $hash, 0, 12), $alt['shared_devices']); + } + + $alt = TimeHelper::normaliseRow($alt, ['last_seen']); + $potentialAlts[] = $alt; + } + + return ApiResponse::success([ + 'potential_alts' => $potentialAlts, + 'source_ips' => $sourceIps, + 'source_devices' => $sourceDevices, + 'total' => count($potentialAlts), + ], 'Potential alt accounts fetched successfully', 200); + } + + #[OA\Delete( + path: '/api/admin/users/{uuid}/devices', + summary: 'Clear device fingerprints for a user', + description: 'Remove all browser/device sync records associated with a user. Useful when clearing false positives or after support review.', + tags: ['Admin - Users'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'User UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Device records cleared successfully'), + new OA\Response(response: 404, description: 'User not found'), + ] + )] + public function clearUserDevices(Request $request, string $uuid): Response + { + $user = User::getUserByUuid($uuid); + if (!$user) { + return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); + } + + if (!\App\Chat\UserDevice::deleteUserData($uuid)) { + return ApiResponse::error('Failed to clear device records', 'DEVICE_CLEAR_FAILED', 500); + } + + $admin = $request->attributes->get('user'); + Activity::createActivity([ + 'user_uuid' => $admin['uuid'] ?? $uuid, + 'name' => 'admin_clear_user_devices', + 'context' => 'Cleared device fingerprints for user ' . $user['username'] . ' (' . $uuid . ')', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([], 'Device records cleared for user', 200); + } + + #[OA\Delete( + path: '/api/admin/devices', + summary: 'Clear all device fingerprints globally', + description: 'Remove every browser/device sync record in the panel. Use after policy changes or to reset alt detection data.', + tags: ['Admin - Users'], + responses: [ + new OA\Response(response: 200, description: 'All device records cleared successfully'), + ] + )] + public function clearAllDevices(Request $request): Response + { + if (!\App\Chat\UserDevice::deleteAll()) { + return ApiResponse::error('Failed to clear device records', 'DEVICE_CLEAR_FAILED', 500); + } + + $admin = $request->attributes->get('user'); + Activity::createActivity([ + 'user_uuid' => $admin['uuid'] ?? '', + 'name' => 'admin_clear_all_devices', + 'context' => 'Cleared all device fingerprint records globally', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([], 'All device records cleared', 200); + } + #[OA\Get( path: '/api/admin/users/serverRequest/{id}', summary: 'Get server request by id (INTERNAL USE ONLY) - DO NOT USE THIS ENDPOINT IN YOUR CODE!', @@ -1297,6 +1499,20 @@ public function serverRequest(Request $request, int $id): Response schema: new OA\Schema(type: 'string', format: 'uuid') ), ], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'expires_in', + type: 'integer', + minimum: 1, + maximum: 1440, + description: 'Token lifetime in minutes (overrides the admin default when provided)' + ), + ] + ) + ), responses: [ new OA\Response( response: 200, @@ -1308,6 +1524,7 @@ public function serverRequest(Request $request, int $id): Response ] ) ), + new OA\Response(response: 400, description: 'Bad request - Invalid expires_in value'), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), new OA\Response(response: 404, description: 'User not found'), @@ -1320,7 +1537,36 @@ public function createSsoToken(Request $request, string $uuid): Response return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); } - $expiresInMinutes = 5; + $config = App::getInstance(true)->getConfig(); + $defaultExpiresInMinutes = (int) $config->getSetting( + ConfigInterface::APP_SSO_TOKEN_LIFETIME_MINUTES, + '5' + ); + if ($defaultExpiresInMinutes < 1 || $defaultExpiresInMinutes > 1440) { + $defaultExpiresInMinutes = 5; + } + + $expiresInMinutes = $defaultExpiresInMinutes; + $body = json_decode($request->getContent(), true); + if (is_array($body) && array_key_exists('expires_in', $body)) { + if (!is_int($body['expires_in']) && !(is_string($body['expires_in']) && ctype_digit($body['expires_in']))) { + return ApiResponse::error( + 'expires_in must be an integer between 1 and 1440', + 'INVALID_EXPIRES_IN', + 400 + ); + } + + $expiresInMinutes = (int) $body['expires_in']; + if ($expiresInMinutes < 1 || $expiresInMinutes > 1440) { + return ApiResponse::error( + 'expires_in must be between 1 and 1440 minutes', + 'INVALID_EXPIRES_IN', + 400 + ); + } + } + $token = SsoToken::createTokenForUser($user['uuid'], $expiresInMinutes); if ($token === null) { return ApiResponse::error('Failed to create SSO token', 'FAILED_TO_CREATE_SSO_TOKEN', 500); @@ -1434,6 +1680,85 @@ public function sendEmail(Request $request, string $uuid): Response ], 'Email queued successfully', 200); } + #[OA\Post( + path: '/api/admin/users/{uuid}/mails/{id}/resend', + summary: 'Resend a failed user email', + description: 'Re-queue a failed system email for delivery to the specified user.', + tags: ['Admin - Users'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'User UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'id', + in: 'path', + description: 'Mail list entry ID', + required: true, + schema: new OA\Schema(type: 'integer', minimum: 1) + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Email queued for resend'), + new OA\Response(response: 400, description: 'Bad request - Mail cannot be resent or SMTP disabled'), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'User or mail not found'), + new OA\Response(response: 500, description: 'Internal server error - Failed to resend mail'), + ] + )] + public function resendMail(Request $request, string $uuid, int $mailId): Response + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $uuid)) { + return ApiResponse::error('Invalid UUID format', 'INVALID_UUID', 400); + } + + if ($mailId <= 0) { + return ApiResponse::error('Invalid mail ID', 'INVALID_MAIL_ID', 400); + } + + $user = User::getUserByUuid($uuid); + if (!$user) { + return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); + } + + $mailListEntry = MailList::getById($mailId); + if (!$mailListEntry || ($mailListEntry['user_uuid'] ?? '') !== $user['uuid']) { + return ApiResponse::error('Mail not found', 'MAIL_NOT_FOUND', 404); + } + + $queueId = (int) ($mailListEntry['queue_id'] ?? 0); + $queueEntry = MailQueue::getById($queueId); + if (!$queueEntry) { + return ApiResponse::error('Mail not found', 'MAIL_NOT_FOUND', 404); + } + + if (($queueEntry['status'] ?? '') !== 'failed') { + return ApiResponse::error('Only failed emails can be resent', 'MAIL_NOT_RESENDABLE', 400); + } + + $app = App::getInstance(true); + if ($app->getConfig()->getSetting(ConfigInterface::SMTP_ENABLED, 'false') !== 'true') { + return ApiResponse::error('SMTP is not enabled', 'SMTP_DISABLED', 400); + } + + if (!MailQueue::retry($queueId)) { + return ApiResponse::error('Failed to queue email for resend', 'FAILED_TO_RESEND_MAIL', 500); + } + + Activity::createActivity([ + 'user_uuid' => $request->attributes->get('user')['uuid'] ?? null, + 'name' => 'resend_user_email', + 'context' => 'Resent failed email to user ' . ($user['username'] ?? $user['uuid']) . '. Subject: ' . ($queueEntry['subject'] ?? ''), + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([], 'Email queued for resend', 200); + } + #[OA\Post( path: '/api/admin/users/{uuid}/verify-email', summary: 'Force verify user email', diff --git a/backend/app/Controllers/Admin/VmInstancesController.php b/backend/app/Controllers/Admin/VmInstancesController.php index 47c05c15d..34e3b1b58 100644 --- a/backend/app/Controllers/Admin/VmInstancesController.php +++ b/backend/app/Controllers/Admin/VmInstancesController.php @@ -1890,6 +1890,7 @@ public function listBackups(Request $request, int $id): Response } $backups = VmInstanceBackup::getBackupsByInstanceId((int) $instance['id']); + $backups = TimeHelper::normaliseRows($backups); $storages = []; $vmNode = VmNode::getVmNodeById((int) $instance['vm_node_id']); diff --git a/backend/app/Controllers/System/PluginSidebarController.php b/backend/app/Controllers/System/PluginSidebarController.php index 99ad7947d..6e27eb35d 100755 --- a/backend/app/Controllers/System/PluginSidebarController.php +++ b/backend/app/Controllers/System/PluginSidebarController.php @@ -83,7 +83,7 @@ public function index(Request $request): Response $currentServerSpellId = null; if (isset($_COOKIE['serverUuid'])) { $serverUuid = $_COOKIE['serverUuid']; - $server = Server::getServerByUuid($serverUuid); + $server = Server::getServerByUuidShort($serverUuid) ?? Server::getServerByUuid($serverUuid); if ($server && isset($server['spell_id'])) { $currentServerSpellId = (int) $server['spell_id']; } diff --git a/backend/app/Controllers/System/SelfTest.php b/backend/app/Controllers/System/SelfTest.php index bfb31d455..bef45a7bb 100644 --- a/backend/app/Controllers/System/SelfTest.php +++ b/backend/app/Controllers/System/SelfTest.php @@ -51,6 +51,8 @@ class SelfTest public function getSelfTest(Request $request): Response { $cacheKey = 'system_self_test'; + $lastGoodKey = 'system_self_test_last_good'; + if (Cache::exists($cacheKey)) { $data = Cache::get($cacheKey); $data['cached'] = true; @@ -118,6 +120,14 @@ public function getSelfTest(Request $request): Response // Cache for 1 hour (60 minutes) if everything is OK if (!$hasErrors) { Cache::put($cacheKey, $result, 60); + Cache::put($lastGoodKey, $result, 1440); + } elseif (Cache::exists($lastGoodKey)) { + // Under load, health checks can fail transiently even when the panel is fine. + // Prefer the last known good result instead of falsely reporting not_ready. + $data = Cache::get($lastGoodKey); + $data['cached'] = true; + + return ApiResponse::success($data, 'System is healthy (last known good)', 200); } return ApiResponse::success($result, $hasErrors ? 'System has issues' : 'System is healthy', 200); diff --git a/backend/app/Controllers/User/Auth/DiscordController.php b/backend/app/Controllers/User/Auth/DiscordController.php index 815a055dc..261346ed5 100755 --- a/backend/app/Controllers/User/Auth/DiscordController.php +++ b/backend/app/Controllers/User/Auth/DiscordController.php @@ -26,6 +26,7 @@ use App\Config\ConfigInterface; use App\CloudFlare\CloudFlareRealIP; use App\Helpers\EmailDomainValidator; +use App\Helpers\UserDeviceRegistrationGuard; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -38,33 +39,21 @@ class DiscordController */ public function login(Request $request): RedirectResponse { - $app = App::getInstance(true); - $config = $app->getConfig(); - - // Check if Discord OAuth is enabled - if ($config->getSetting(ConfigInterface::DISCORD_OAUTH_ENABLED, 'false') !== 'true') { - return new RedirectResponse('/auth/login?error=discord_disabled'); - } - - $clientId = $config->getSetting(ConfigInterface::DISCORD_OAUTH_CLIENT_ID, ''); - $clientSecret = $config->getSetting(ConfigInterface::DISCORD_OAUTH_CLIENT_SECRET, ''); + return $this->startDiscordFlow($request); + } - if (empty($clientId) || empty($clientSecret)) { - return new RedirectResponse('/auth/login?error=discord_not_configured'); + /** + * Initiate Discord account linking for an authenticated user. + * GET /api/user/auth/discord/link. + */ + public function linkAccount(Request $request): RedirectResponse + { + $user = $request->attributes->get('user'); + if (!$user || empty($user['uuid'])) { + return new RedirectResponse('/auth/login?redirect=' . urlencode('/dashboard/account?tab=settings')); } - // Generate state for CSRF protection - $state = bin2hex(random_bytes(16)); - - // Store state in cache with 10 minute expiration - Cache::put('discord_oauth_state_' . $state, true, 10); - - // Build Discord OAuth URL - $redirectUri = urlencode(rtrim($app->getConfig()->getSetting(ConfigInterface::APP_URL, ''), '/') . '/api/user/auth/discord/callback'); - $scopes = urlencode('identify email'); - $url = "https://discord.com/api/oauth2/authorize?client_id={$clientId}&redirect_uri={$redirectUri}&response_type=code&scope={$scopes}&state={$state}"; - - return new RedirectResponse($url); + return $this->startDiscordFlow($request, (string) $user['uuid']); } /** @@ -85,10 +74,16 @@ public function callback(Request $request): RedirectResponse } // Validate state - if (!$state || !Cache::exists('discord_oauth_state_' . $state)) { + $cachedState = $state ? Cache::get('discord_oauth_state_' . $state) : null; + if (!$state || $cachedState === null) { return new RedirectResponse('/auth/login?error=invalid_state'); } + $isLinkFlow = is_array($cachedState) + && isset($cachedState['link_user_uuid']) + && is_string($cachedState['link_user_uuid']) + && $cachedState['link_user_uuid'] !== ''; + // Remove used state Cache::forget('discord_oauth_state_' . $state); @@ -121,7 +116,7 @@ public function callback(Request $request): RedirectResponse if (!isset($tokenData['access_token'])) { $app->getLogger()->error('Discord OAuth token exchange failed. HTTP ' . $httpCode . ' Response: ' . ($tokenResponse ?: 'Empty')); - return new RedirectResponse('/auth/login?error=discord_token_failed'); + return $this->discordErrorRedirect('discord_token_failed', $isLinkFlow); } $accessToken = $tokenData['access_token']; @@ -138,7 +133,7 @@ public function callback(Request $request): RedirectResponse // curl_close() is deprecated in PHP 8.5 (no-op since PHP 8.0) if (!isset($discordUser['id'])) { - return new RedirectResponse('/auth/login?error=discord_user_failed'); + return $this->discordErrorRedirect('discord_user_failed', $isLinkFlow); } $discordId = $discordUser['id']; @@ -147,29 +142,48 @@ public function callback(Request $request): RedirectResponse $discordName = $discordUsername . '#' . $discordDiscriminator; $discordEmail = $discordUser['email'] ?? ''; + if ($isLinkFlow) { + return $this->completeAccountLink( + $cachedState['link_user_uuid'], + $discordId, + $accessToken, + $discordUsername, + $discordName + ); + } + // Check if user exists with this Discord ID $existingUser = $this->findUserByDiscordId($discordId); - if ($existingUser && $existingUser['discord_oauth2_linked'] === 'true') { - // User exists and is already linked, generate temporary token and redirect to login - $tempToken = bin2hex(random_bytes(32)); - - // Store user UUID and Discord data with temporary token (5 minute expiration) - Cache::put('discord_login_token_' . $tempToken, [ - 'user_uuid' => $existingUser['uuid'], - 'discord_id' => $discordId, - 'discord_access_token' => $accessToken, - 'discord_username' => $discordUsername, - 'discord_name' => $discordName, - ], 5); + if ($existingUser !== null) { + return $this->createDiscordLoginRedirect( + $existingUser, + $discordId, + $accessToken, + $discordUsername, + $discordName + ); + } - return new RedirectResponse('/auth/login?discord_token=' . $tempToken); + // Discord OAuth proves email ownership — auto-link and log in existing accounts + if (!empty($discordEmail) && filter_var($discordEmail, FILTER_VALIDATE_EMAIL)) { + $emailUser = User::getUserByEmail($discordEmail); + if ($emailUser !== null && ($emailUser['deleted'] ?? 'false') !== 'true') { + return $this->createDiscordLoginRedirect( + $emailUser, + $discordId, + $accessToken, + $discordUsername, + $discordName + ); + } } - // User not linked yet — try to auto-register if registration is enabled + // User not linked yet — offer registration only when no matching account exists $registrationEnabled = $config->getSetting(ConfigInterface::REGISTRATION_ENABLED, 'true') === 'true'; + $matchingUser = $this->findMatchingUserForDiscord($discordUsername, $discordEmail); - if ($registrationEnabled) { + if ($registrationEnabled && $matchingUser === null) { $tempToken = bin2hex(random_bytes(32)); // Store Discord data with temporary token (10 minute expiration for registration) @@ -185,7 +199,7 @@ public function callback(Request $request): RedirectResponse return new RedirectResponse('/auth/register?discord_link_token=' . $tempToken); } - // Registration is disabled or provisioning failed — fall back to the link-to-existing-account flow + // Registration is disabled or a matching account already exists — link to existing account $tempToken = bin2hex(random_bytes(32)); // Store Discord data with temporary token (10 minute expiration for linking) @@ -358,6 +372,36 @@ public function register(Request $request): Response return ApiResponse::error('Registration is disabled', 'REGISTRATION_DISABLED', 403); } + $deviceLimitResponse = UserDeviceRegistrationGuard::assertRegistrationAllowed($request, $config); + if ($deviceLimitResponse !== null) { + return $deviceLimitResponse; + } + + $existingUser = $this->findUserByDiscordId($cached['discord_id']); + if ($existingUser !== null) { + Cache::forget('discord_link_token_' . $token); + + return (new LoginController())->completeLogin($existingUser); + } + + if (!empty($cached['discord_email']) && filter_var($cached['discord_email'], FILTER_VALIDATE_EMAIL)) { + $emailUser = User::getUserByEmail($cached['discord_email']); + if ($emailUser !== null && ($emailUser['deleted'] ?? 'false') !== 'true') { + $this->applyDiscordLink( + $emailUser['uuid'], + $cached['discord_id'], + $cached['discord_access_token'], + $cached['discord_username'], + $cached['discord_name'] + ); + + Cache::forget('discord_link_token_' . $token); + $linkedUser = User::getUserByUuid($emailUser['uuid']); + + return (new LoginController())->completeLogin($linkedUser ?? $emailUser); + } + } + $newUser = $this->autoProvisionUser( $cached['discord_id'], $cached['discord_username'], @@ -429,11 +473,29 @@ private function autoProvisionUser( return null; } - // Check if email is already in use - if (User::getUserByEmail($discordEmail) !== null) { - $app->getLogger()->warning('Discord auto-provision: email already in use: ' . $discordEmail); - - return null; + // Re-use an existing account when the Discord email is already registered + $existingUser = User::getUserByEmail($discordEmail); + if ($existingUser !== null) { + if (($existingUser['deleted'] ?? 'false') === 'true') { + $app->getLogger()->warning('Discord auto-provision: email belongs to a deleted user: ' . $discordEmail); + + return null; + } + + $linked = $this->applyDiscordLink( + $existingUser['uuid'], + $discordId, + $accessToken, + $discordUsername, + $discordName + ); + if (!$linked) { + $app->getLogger()->error('Discord auto-provision: failed to link existing user for email: ' . $discordEmail); + + return null; + } + + return User::getUserByUuid($existingUser['uuid']); } $emailValue = $discordEmail; } else { @@ -460,17 +522,26 @@ private function autoProvisionUser( $username = substr($baseUsername . '_' . $suffix, 0, 32); } - $userId = User::createUser([ - 'username' => $username, - 'first_name' => $firstName, - 'last_name' => $lastName, - 'email' => $emailValue, - 'password' => $hashedPassword, - 'uuid' => $uuid, - 'remember_token' => User::generateAccountToken(), - 'first_ip' => $ip, - 'last_ip' => $ip, - ], true); + if (User::getUserByUsername($username) !== null) { + continue; + } + + try { + $userId = User::createUser([ + 'username' => $username, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $emailValue, + 'password' => $hashedPassword, + 'uuid' => $uuid, + 'remember_token' => User::generateAccountToken(), + 'first_ip' => $ip, + 'last_ip' => $ip, + ], true); + } catch (\PDOException $e) { + $app->getLogger()->warning('Discord auto-provision username collision for ' . $username . ': ' . $e->getMessage()); + $userId = false; + } if ($userId !== false) { break; @@ -509,4 +580,138 @@ private function autoProvisionUser( return $createdUser; } + + private function startDiscordFlow(Request $request, ?string $linkUserUuid = null): RedirectResponse + { + $app = App::getInstance(true); + $config = $app->getConfig(); + $isLinkFlow = $linkUserUuid !== null; + + // Check if Discord OAuth is enabled + if ($config->getSetting(ConfigInterface::DISCORD_OAUTH_ENABLED, 'false') !== 'true') { + return $this->discordErrorRedirect('discord_disabled', $isLinkFlow); + } + + $clientId = $config->getSetting(ConfigInterface::DISCORD_OAUTH_CLIENT_ID, ''); + $clientSecret = $config->getSetting(ConfigInterface::DISCORD_OAUTH_CLIENT_SECRET, ''); + + if (empty($clientId) || empty($clientSecret)) { + return $this->discordErrorRedirect('discord_not_configured', $isLinkFlow); + } + + // Generate state for CSRF protection + $state = bin2hex(random_bytes(16)); + $stateData = []; + if ($isLinkFlow) { + $stateData['link_user_uuid'] = $linkUserUuid; + } + + // Store state in cache with 10 minute expiration + Cache::put('discord_oauth_state_' . $state, $stateData, 10); + + // Build Discord OAuth URL + $redirectUri = urlencode(rtrim($app->getConfig()->getSetting(ConfigInterface::APP_URL, ''), '/') . '/api/user/auth/discord/callback'); + $scopes = urlencode('identify email'); + $url = "https://discord.com/api/oauth2/authorize?client_id={$clientId}&redirect_uri={$redirectUri}&response_type=code&scope={$scopes}&state={$state}"; + + return new RedirectResponse($url); + } + + private function completeAccountLink( + string $userUuid, + string $discordId, + string $accessToken, + string $discordUsername, + string $discordName, + ): RedirectResponse { + $user = User::getUserByUuid($userUuid); + if (!$user) { + return new RedirectResponse('/dashboard/account?tab=settings&error=discord_user_not_found'); + } + + if (($user['discord_oauth2_linked'] ?? 'false') === 'true') { + return new RedirectResponse('/dashboard/account?tab=settings&error=discord_already_linked'); + } + + $existingLinkedUser = $this->findUserByDiscordId($discordId); + if ($existingLinkedUser && $existingLinkedUser['uuid'] !== $userUuid) { + return new RedirectResponse('/dashboard/account?tab=settings&error=discord_already_linked'); + } + + $updated = User::updateUser($userUuid, [ + 'discord_oauth2_id' => $discordId, + 'discord_oauth2_access_token' => $accessToken, + 'discord_oauth2_linked' => 'true', + 'discord_oauth2_username' => $discordUsername, + 'discord_oauth2_name' => $discordName, + ]); + + if (!$updated) { + return new RedirectResponse('/dashboard/account?tab=settings&error=discord_link_failed'); + } + + return new RedirectResponse('/dashboard/account?tab=settings&linked=discord'); + } + + private function discordErrorRedirect(string $errorCode, bool $accountSettings): RedirectResponse + { + $target = $accountSettings ? '/dashboard/account?tab=settings&error=' : '/auth/login?error='; + + return new RedirectResponse($target . urlencode($errorCode)); + } + + private function findMatchingUserForDiscord(string $discordUsername, string $discordEmail): ?array + { + if (!empty($discordEmail) && filter_var($discordEmail, FILTER_VALIDATE_EMAIL)) { + $user = User::getUserByEmail($discordEmail); + if ($user !== null && ($user['deleted'] ?? 'false') !== 'true') { + return $user; + } + } + + $baseUsername = preg_replace('/[^a-zA-Z0-9_]/', '_', strtolower($discordUsername)) ?: 'user'; + $user = User::getUserByUsername(substr($baseUsername, 0, 32)); + if ($user !== null && ($user['deleted'] ?? 'false') !== 'true') { + return $user; + } + + return null; + } + + private function createDiscordLoginRedirect( + array $user, + string $discordId, + string $accessToken, + string $discordUsername, + string $discordName, + ): RedirectResponse { + $this->applyDiscordLink($user['uuid'], $discordId, $accessToken, $discordUsername, $discordName); + + $tempToken = bin2hex(random_bytes(32)); + Cache::put('discord_login_token_' . $tempToken, [ + 'user_uuid' => $user['uuid'], + 'discord_id' => $discordId, + 'discord_access_token' => $accessToken, + 'discord_username' => $discordUsername, + 'discord_name' => $discordName, + ], 5); + + return new RedirectResponse('/auth/login?discord_token=' . $tempToken); + } + + private function applyDiscordLink( + string $userUuid, + string $discordId, + string $accessToken, + string $discordUsername, + string $discordName, + ): bool { + return User::updateUser($userUuid, [ + 'discord_oauth2_id' => $discordId, + 'discord_oauth2_access_token' => $accessToken, + 'discord_oauth2_linked' => 'true', + 'discord_oauth2_username' => $discordUsername, + 'discord_oauth2_name' => $discordName, + ]); + } } diff --git a/backend/app/Controllers/User/Auth/LoginController.php b/backend/app/Controllers/User/Auth/LoginController.php index dd150d3a2..4bc4b0773 100755 --- a/backend/app/Controllers/User/Auth/LoginController.php +++ b/backend/app/Controllers/User/Auth/LoginController.php @@ -25,6 +25,7 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Config\ConfigInterface; +use App\Helpers\UserDeviceTracker; use App\CloudFlare\CloudFlareRealIP; use App\Plugins\Events\Events\AuthEvent; use Symfony\Component\HttpFoundation\Request; @@ -280,6 +281,7 @@ public function completeLogin(array $userInfo, ?string $redirectTo = null): Resp $userInfo['remember_token'] = $token; setcookie('remember_token', $token, time() + 60 * 60 * 24 * 30, '/'); User::updateUser($userInfo['uuid'], ['last_ip' => CloudFlareRealIP::getRealIP()]); + UserDeviceTracker::trackFromGlobals($userInfo); Activity::createActivity([ 'user_uuid' => $userInfo['uuid'], diff --git a/backend/app/Controllers/User/Auth/RegisterController.php b/backend/app/Controllers/User/Auth/RegisterController.php index 8936e523f..b90360a1b 100755 --- a/backend/app/Controllers/User/Auth/RegisterController.php +++ b/backend/app/Controllers/User/Auth/RegisterController.php @@ -26,10 +26,12 @@ use OpenApi\Attributes as OA; use App\Config\ConfigInterface; use App\Mail\templates\Welcome; +use App\Helpers\UserDeviceTracker; use App\Mail\templates\VerifyEmail; use App\CloudFlare\CloudFlareRealIP; use App\Helpers\EmailDomainValidator; use App\Plugins\Events\Events\AuthEvent; +use App\Helpers\UserDeviceRegistrationGuard; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -204,6 +206,24 @@ public function put(Request $request): Response return ApiResponse::error('Email already exists', 'EMAIL_ALREADY_EXISTS'); } + $deviceLimitResponse = UserDeviceRegistrationGuard::assertRegistrationAllowed($request, $config); + if ($deviceLimitResponse !== null) { + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + AuthEvent::onAuthRegistrationFailed(), + [ + 'email' => $data['email'], + 'username' => $data['username'], + 'reason' => 'DEVICE_ACCOUNT_LIMIT', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ] + ); + } + + return $deviceLimitResponse; + } + $tempPassword = $data['password']; $emailVerificationToken = $requiresEmailVerification ? bin2hex(random_bytes(32)) : null; // Create user @@ -289,6 +309,8 @@ public function put(Request $request): Response 'ip_address' => CloudFlareRealIP::getRealIP(), ]); + UserDeviceTracker::trackFromRequest($request, $createdUser); + if (!$requiresEmailVerification) { // Automatically log in the user after registration // Set session/cookie diff --git a/backend/app/Controllers/User/Server/Files/ServerFilesController.php b/backend/app/Controllers/User/Server/Files/ServerFilesController.php index 140f5ae02..c50481011 100755 --- a/backend/app/Controllers/User/Server/Files/ServerFilesController.php +++ b/backend/app/Controllers/User/Server/Files/ServerFilesController.php @@ -2291,12 +2291,7 @@ private function validateNode(int $nodeId): array */ private function createWingsConnection(array $node, int $timeout = 30): Wings { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; - $token = $node['daemon_token']; - - return new Wings($host, $port, $scheme, $token, $timeout); + return Wings::fromNode($node, $timeout); } /** diff --git a/backend/app/Controllers/User/Server/ServerBackupController.php b/backend/app/Controllers/User/Server/ServerBackupController.php index 22342ad90..5d1bedd8f 100755 --- a/backend/app/Controllers/User/Server/ServerBackupController.php +++ b/backend/app/Controllers/User/Server/ServerBackupController.php @@ -21,11 +21,13 @@ use App\Chat\Node; use App\Chat\Backup; use App\Chat\Server; +use App\Helpers\TimeHelper; use App\SubuserPermissions; use App\Chat\ServerActivity; use App\Helpers\ApiResponse; use App\Services\Wings\Wings; use OpenApi\Attributes as OA; +use App\Helpers\WingsUrlHelper; use App\Plugins\Events\Events\ServerEvent; use App\Services\Backup\BackupFifoEviction; use Symfony\Component\HttpFoundation\Request; @@ -181,6 +183,7 @@ public function getBackups(Request $request, string $serverUuid): Response $total = count($backups); $offset = ($page - 1) * $perPage; $paginatedBackups = array_slice($backups, $offset, $perPage); + $paginatedBackups = TimeHelper::normaliseRows($paginatedBackups, ['completed_at', 'deleted_at']); $retention = BackupFifoEviction::retentionMetaForServer($server); @@ -268,7 +271,7 @@ public function getBackup(Request $request, string $serverUuid, string $backupUu return ApiResponse::error('Backup not found', 'BACKUP_NOT_FOUND', 404); } - return ApiResponse::success($backup); + return ApiResponse::success(TimeHelper::normaliseRow($backup, ['completed_at', 'deleted_at'])); } /** @@ -1063,16 +1066,14 @@ public function getBackupDownloadUrl(Request $request, string $serverUuid, strin } try { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; $token = $node['daemon_token']; + $wingsBaseUrl = WingsUrlHelper::buildFromNode($node); // Create JWT service instance $jwtService = new \App\Services\Wings\Services\JwtService( $token, // Node secret App::getInstance(true)->getConfig()->getSetting(\App\Config\ConfigInterface::APP_URL, 'https://devsv.mythical.systems'), // Panel URL - $scheme . '://' . $host . ':' . $port // Wings URL + $wingsBaseUrl // Wings URL ); // Get user permissions @@ -1110,7 +1111,7 @@ public function getBackupDownloadUrl(Request $request, string $serverUuid, strin } // Construct the download URL - $baseUrl = rtrim($scheme . '://' . $host . ':' . $port, '/'); + $baseUrl = rtrim($wingsBaseUrl, '/'); $downloadUrl = "{$baseUrl}/download/backup?token={$jwtToken}&server={$serverUuid}&backup={$backupUuid}"; // Log activity diff --git a/backend/app/Controllers/User/Server/ServerFastDlController.php b/backend/app/Controllers/User/Server/ServerFastDlController.php index f2f6fe24a..743d7f7f3 100644 --- a/backend/app/Controllers/User/Server/ServerFastDlController.php +++ b/backend/app/Controllers/User/Server/ServerFastDlController.php @@ -26,6 +26,7 @@ use App\Services\Wings\Wings; use OpenApi\Attributes as OA; use App\Config\ConfigInterface; +use App\Helpers\WingsUrlHelper; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -538,20 +539,7 @@ public function updateFastDl(Request $request, int $serverId): Response */ private function createWings(array $node): Wings { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; - $token = $node['daemon_token']; - - $timeout = (int) 30; - - return new Wings( - $host, - $port, - $scheme, - $token, - $timeout - ); + return Wings::fromNode($node, 30); } /** @@ -561,11 +549,7 @@ private function createWings(array $node): Wings */ private function buildFastDlUrl(array $node, array $server, string $directory): string { - $scheme = $node['scheme'] ?? 'http'; - $host = $node['fqdn'] ?? 'localhost'; - $port = (int) ($node['daemonListen'] ?? 80); - - $base = rtrim(sprintf('%s://%s:%d', $scheme, $host, $port), '/'); + $base = rtrim(WingsUrlHelper::buildFromNode($node), '/'); $dir = trim($directory) !== '' ? trim($directory) : 'fastdl'; return $base . '/' . $server['uuid'] . '/' . $dir; diff --git a/backend/app/Controllers/User/Server/ServerFirewallController.php b/backend/app/Controllers/User/Server/ServerFirewallController.php index 8fc8e4438..1d9c29bd1 100644 --- a/backend/app/Controllers/User/Server/ServerFirewallController.php +++ b/backend/app/Controllers/User/Server/ServerFirewallController.php @@ -874,20 +874,7 @@ private function validateIpOrCidr(string $ipOrCidr): bool */ private function createWings(array $node): Wings { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; - $token = $node['daemon_token']; - - $timeout = (int) 30; - - return new Wings( - $host, - $port, - $scheme, - $token, - $timeout - ); + return Wings::fromNode($node, 30); } /** diff --git a/backend/app/Controllers/User/Server/ServerImportController.php b/backend/app/Controllers/User/Server/ServerImportController.php index 835c19bdd..b3cc3558e 100644 --- a/backend/app/Controllers/User/Server/ServerImportController.php +++ b/backend/app/Controllers/User/Server/ServerImportController.php @@ -406,13 +406,7 @@ private function validateImportPayload(array $payload): ?Response */ private function createWings(array $node): Wings { - $scheme = $node['scheme'] ?? 'http'; - $host = $node['fqdn'] ?? 'localhost'; - $port = $node['daemonListen'] ?? 8443; - $token = $node['daemon_token'] ?? ''; - $timeout = 30; - - return new Wings($host, $port, $scheme, $token, $timeout); + return Wings::fromNode($node, 30); } private static function emitEvent(string $eventName, array $payload): void diff --git a/backend/app/Controllers/User/Server/ServerProxyController.php b/backend/app/Controllers/User/Server/ServerProxyController.php index 6b51d810e..f4e31de45 100644 --- a/backend/app/Controllers/User/Server/ServerProxyController.php +++ b/backend/app/Controllers/User/Server/ServerProxyController.php @@ -728,20 +728,7 @@ private function validateCreatePayload(array $payload, array $server): ?Response */ private function createWings(array $node): Wings { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; - $token = $node['daemon_token']; - - $timeout = (int) 30; - - return new Wings( - $host, - $port, - $scheme, - $token, - $timeout - ); + return Wings::fromNode($node, 30); } /** diff --git a/backend/app/Controllers/User/Server/ServerUserController.php b/backend/app/Controllers/User/Server/ServerUserController.php index 73887d550..e5791e9e3 100755 --- a/backend/app/Controllers/User/Server/ServerUserController.php +++ b/backend/app/Controllers/User/Server/ServerUserController.php @@ -37,6 +37,7 @@ use OpenApi\Attributes as OA; use App\Chat\DatabaseInstance; use App\Config\ConfigInterface; +use App\Helpers\WingsUrlHelper; use App\Helpers\PermissionHelper; use App\Chat\ServerCustomVariable; use App\CloudFlare\CloudFlareRealIP; @@ -263,6 +264,7 @@ public function getUserServers(Request $request): Response $page = (int) $request->query->get('page', 1); $limit = (int) $request->query->get('limit', 10); $search = $request->query->get('search', ''); + $statusFilter = self::parseStatusFilter($request); // Explicitly check for 'true' string - dashboard should never pass this, only admin area $viewAllParam = $request->query->get('view_all', 'false'); $viewAll = ($viewAllParam === 'true' || $viewAllParam === true || $viewAllParam === '1' || $viewAllParam === 1); @@ -287,7 +289,7 @@ public function getUserServers(Request $request): Response // Use admin search to get all servers (fetch all then paginate) // First get total count for pagination - $total = Server::getCount($search); + $total = Server::getCount($search, status: $statusFilter); // Get all servers matching the search (we'll paginate in memory) // Fetch a large batch to handle all cases @@ -297,7 +299,8 @@ public function getUserServers(Request $request): Response search: $search, fields: [], sortBy: 'id', - sortOrder: 'DESC' + sortOrder: 'DESC', + status: $statusFilter, ); // Apply pagination to all servers @@ -351,6 +354,13 @@ public function getUserServers(Request $request): Response // Combine owned and subuser servers $allServers = array_merge($ownedServers, $subuserServers); + if ($statusFilter !== null) { + $allServers = array_values(array_filter( + $allServers, + static fn (array $server): bool => ($server['status'] ?? null) === $statusFilter + )); + } + // Get total count before pagination $totalServers = count($allServers); @@ -494,6 +504,7 @@ public function getAdminAllOtherServers(Request $request): Response $page = (int) $request->query->get('page', 1); $limit = (int) $request->query->get('limit', 10); $search = $request->query->get('search', ''); + $statusFilter = self::parseStatusFilter($request); if ($page < 1) { $page = 1; @@ -505,7 +516,7 @@ public function getAdminAllOtherServers(Request $request): Response $limit = 100; } - $total = Server::getCount($search, null, null, null, null, (int) $user['id']); + $total = Server::getCount($search, null, null, null, null, (int) $user['id'], status: $statusFilter); $servers = Server::searchServers( page: $page, limit: $limit, @@ -515,6 +526,7 @@ public function getAdminAllOtherServers(Request $request): Response sortOrder: 'DESC', ownerId: null, excludeOwnerId: (int) $user['id'], + status: $statusFilter, ); foreach ($servers as &$server) { @@ -698,6 +710,7 @@ public function getServer(Request $request, string $uuidShort): Response 'banner' => $spellData['banner'] ?? null, 'startup' => $spellData['startup'] ?? null, 'docker_images' => $spellData['docker_images'] ?? null, + 'default_docker_image' => $spellData['default_docker_image'] ?? null, // Features & additional JSON-config fields (decoded further down if JSON) 'features' => $spellData['features'] ?? null, 'file_denylist' => $spellData['file_denylist'] ?? null, @@ -898,16 +911,14 @@ public function generateServerJwt(Request $request, string $uuidShort): Response } try { - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; $token = $node['daemon_token']; + $wingsBaseUrl = WingsUrlHelper::buildFromNode($node); // Create JWT service instance $jwtService = new JwtService( $token, // Node secret App::getInstance(true)->getConfig()->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), // Panel URL - $scheme . '://' . $host . ':' . $port // Wings URL + $wingsBaseUrl // Wings URL ); // Get user permissions @@ -920,19 +931,13 @@ public function generateServerJwt(Request $request, string $uuidShort): Response $permissions ); - if ($scheme == 'http') { - $scheme = 'ws'; - } else { - $scheme = 'wss'; - } - return ApiResponse::success([ 'token' => $token, 'expires_at' => time() + 600, // 10 minutes from now 'server_uuid' => $server['uuid'], 'user_uuid' => $user['uuid'], 'permissions' => $permissions, - 'connection_string' => $scheme . '://' . $host . ':' . $port . '/api/servers/' . $server['uuid'] . '/ws', + 'connection_string' => WingsUrlHelper::toWebSocketBaseUrl($wingsBaseUrl) . '/api/servers/' . $server['uuid'] . '/ws', ], 'JWT token generated successfully', 200); } catch (\Exception $e) { return ApiResponse::error('Failed to generate JWT token: ' . $e->getMessage(), 'JWT_GENERATION_FAILED', 500); @@ -1157,6 +1162,36 @@ public function updateServer(Request $request, string $uuidShort): Response if (strlen($image) > 191) { return ApiResponse::error('Docker image is too long (max 191 characters)', 'IMAGE_TOO_LONG', 400); } + + $app = App::getInstance(true); + $allowCustomDockerImage = $app->getConfig()->getSetting(ConfigInterface::SERVER_ALLOW_CUSTOM_DOCKER_IMAGE, 'false'); + $allowCustomDockerImage = ($allowCustomDockerImage === 'true' || $allowCustomDockerImage === true || $allowCustomDockerImage === '1' || $allowCustomDockerImage === 1); + + if (!$allowCustomDockerImage) { + $spellIdForImage = isset($data['spell_id']) ? (int) $data['spell_id'] : (int) $server['spell_id']; + $spellForImage = Spell::getSpellById($spellIdForImage); + if (!$spellForImage) { + return ApiResponse::error('Spell not found for Docker image validation', 'SPELL_NOT_FOUND', 404); + } + + $allowedImages = Spell::parseDockerImages($spellForImage['docker_images'] ?? null); + $currentServerImage = trim((string) ($server['image'] ?? '')); + if ($currentServerImage !== '' && !in_array($currentServerImage, $allowedImages, true)) { + $allowedImages[] = $currentServerImage; + } + $spellDefaultImage = Spell::resolveDefaultDockerImage($spellForImage); + if ($spellDefaultImage !== '' && !in_array($spellDefaultImage, $allowedImages, true)) { + $allowedImages[] = $spellDefaultImage; + } + if ($allowedImages === [] || !in_array($image, $allowedImages, true)) { + return ApiResponse::error( + 'Docker image must be one of the images configured for this spell', + 'INVALID_DOCKER_IMAGE', + 400 + ); + } + } + $updateData['image'] = $image; } @@ -2082,6 +2117,23 @@ public function deleteServer(Request $request, string $uuidShort): Response return ApiResponse::success([], 'Server deleted successfully', 200); } + /** + * Parse optional status filter from query parameters. + * Supports `status=running` or legacy `running_only=true`. + */ + private static function parseStatusFilter(Request $request): ?string + { + $status = trim((string) $request->query->get('status', '')); + if ($status !== '') { + return $status; + } + + $runningOnlyParam = $request->query->get('running_only', 'false'); + $runningOnly = ($runningOnlyParam === 'true' || $runningOnlyParam === true || $runningOnlyParam === '1' || $runningOnlyParam === 1); + + return $runningOnly ? 'running' : null; + } + /** * Validate a variable value against a rules string (e.g., "required|string|max:20", "required|regex:/^foo$/"). * Returns an error message string if invalid, or null if valid. diff --git a/backend/app/Controllers/User/Server/SubuserController.php b/backend/app/Controllers/User/Server/SubuserController.php index 9a2db3f42..8d4292219 100755 --- a/backend/app/Controllers/User/Server/SubuserController.php +++ b/backend/app/Controllers/User/Server/SubuserController.php @@ -899,13 +899,7 @@ public function getValidPermissions(Request $request, string $serverUuid): Respo */ private function createWings(array $node): Wings { - $scheme = $node['scheme'] ?? 'http'; - $host = $node['fqdn'] ?? 'localhost'; - $port = $node['daemonListen'] ?? 8443; - $token = $node['daemon_token'] ?? ''; - $timeout = 30; - - return new Wings($host, $port, $scheme, $token, $timeout); + return Wings::fromNode($node, 30); } /** diff --git a/backend/app/Controllers/User/TicketsController.php b/backend/app/Controllers/User/TicketsController.php index 46ce33966..fc7cac0eb 100755 --- a/backend/app/Controllers/User/TicketsController.php +++ b/backend/app/Controllers/User/TicketsController.php @@ -21,6 +21,7 @@ use App\Chat\User; use App\Chat\Server; use App\Chat\Ticket; +use App\Permissions; use App\Chat\Activity; use App\Chat\TicketStatus; use App\Chat\TicketMessage; @@ -30,9 +31,11 @@ use OpenApi\Attributes as OA; use App\Chat\TicketAttachment; use App\Config\ConfigInterface; +use App\Helpers\PermissionHelper; use App\Middleware\AuthMiddleware; use App\CloudFlare\CloudFlareRealIP; use App\Plugins\Events\Events\TicketEvent; +use App\Services\Tickets\TicketAdminNotifier; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -110,6 +113,8 @@ public function index(Request $request): Response $search = $request->query->get('search', ''); $statusId = $request->query->get('status_id'); $categoryId = $request->query->get('category_id'); + $isAdminViewer = PermissionHelper::hasPermission($user['uuid'], Permissions::ADMIN_TICKETS_VIEW); + $scope = $request->query->get('scope', $isAdminViewer ? 'all_open' : 'mine'); if ($page < 1) { $page = 1; @@ -124,13 +129,40 @@ public function index(Request $request): Response $offset = ($page - 1) * $limit; $searchQuery = $search && trim($search) !== '' ? trim($search) : null; - // Get tickets for this user - $tickets = Ticket::getAll($searchQuery, $limit, $offset, $user['uuid'], null, $categoryId, $statusId); - $total = Ticket::getCount($searchQuery, $user['uuid'], null, $categoryId, $statusId); + $filterUserUuid = $user['uuid']; + $openOnly = false; + $isAdminView = false; + + if ($isAdminViewer && in_array($scope, ['all_open', 'all'], true)) { + $isAdminView = true; + $filterUserUuid = null; + if ($scope === 'all_open' && ($statusId === null || $statusId === '')) { + $openOnly = true; + } + } + + $tickets = Ticket::getAll( + $searchQuery, + $limit, + $offset, + $filterUserUuid, + null, + $categoryId ? (int) $categoryId : null, + $statusId ? (int) $statusId : null, + $openOnly + ); + $total = Ticket::getCount( + $searchQuery, + $filterUserUuid, + null, + $categoryId ? (int) $categoryId : null, + $statusId ? (int) $statusId : null, + $openOnly + ); // Enrich tickets with related data foreach ($tickets as &$ticket) { - $ticket = $this->enrichTicketData($ticket); + $ticket = $this->enrichTicketData($ticket, $isAdminView); $unreadMeta = TicketMessage::getUnreadSinceLastReply((int) $ticket['id'], $user['uuid']); $ticket['unread_count'] = $unreadMeta['unread_count']; $ticket['has_unread_messages_since_last_reply'] = $unreadMeta['has_unread']; @@ -140,7 +172,7 @@ public function index(Request $request): Response $from = $total > 0 ? $offset + 1 : 0; $to = min($offset + $limit, $total); - return ApiResponse::success([ + $responseData = [ 'tickets' => $tickets, 'pagination' => [ 'current_page' => $page, @@ -152,7 +184,15 @@ public function index(Request $request): Response 'from' => $from, 'to' => $to, ], - ], 'Tickets retrieved successfully', 200); + ]; + + if ($isAdminViewer) { + $responseData['is_admin_view'] = $isAdminView; + $responseData['scope'] = $scope; + $responseData['open_tickets_count'] = Ticket::getGlobalOpenTicketsCount(); + } + + return ApiResponse::success($responseData, 'Tickets retrieved successfully', 200); } #[OA\Get( @@ -191,13 +231,15 @@ public function show(Request $request, string $uuid): Response return ApiResponse::error('Ticket not found', 'TICKET_NOT_FOUND', 404); } - // Verify ticket belongs to user - if ($ticket['user_uuid'] !== $user['uuid']) { + $isAdminViewer = PermissionHelper::hasPermission($user['uuid'], Permissions::ADMIN_TICKETS_VIEW); + + // Verify ticket belongs to user unless staff can view all tickets + if ($ticket['user_uuid'] !== $user['uuid'] && !$isAdminViewer) { return ApiResponse::error('Access denied', 'ACCESS_DENIED', 403); } // Enrich ticket data - $ticket = $this->enrichTicketData($ticket); + $ticket = $this->enrichTicketData($ticket, $isAdminViewer && $ticket['user_uuid'] !== $user['uuid']); // Get messages for this ticket (exclude internal notes from regular users) $messages = array_values(array_filter( @@ -395,6 +437,8 @@ public function create(Request $request): Response ); } + TicketAdminNotifier::notify($ticket, 'new_ticket', $user['uuid']); + return ApiResponse::success([ 'ticket' => $ticket, 'message_id' => $messageId, @@ -501,6 +545,8 @@ public function reply(Request $request, string $uuid): Response ); } + TicketAdminNotifier::notify($ticket, 'user_reply', $user['uuid']); + return ApiResponse::success([ 'message' => $message, 'message_id' => $messageId, @@ -1070,7 +1116,7 @@ public function getServers(Request $request): Response /** * Enrich ticket data with related information. */ - private function enrichTicketData(array $ticket): array + private function enrichTicketData(array $ticket, bool $includeUser = false): array { // Add category if (isset($ticket['category_id'])) { @@ -1116,6 +1162,16 @@ private function enrichTicketData(array $ticket): array $ticket['server'] = null; } + if ($includeUser && !empty($ticket['user_uuid'])) { + $ticketUser = User::getUserByUuid((string) $ticket['user_uuid']); + $ticket['user'] = $ticketUser ? [ + 'uuid' => $ticketUser['uuid'], + 'username' => $ticketUser['username'], + 'email' => $ticketUser['email'], + 'avatar' => $ticketUser['avatar'] ?? null, + ] : null; + } + return $ticket; } @@ -1141,8 +1197,10 @@ private function enrichMessageData(array $message): array if ($role) { $message['user']['role'] = [ 'id' => (int) $role['id'], - 'name' => $role['display_name'] ?? $role['name'], // Use display_name if available, fallback to name - 'color' => $role['color'] ?? null, // Include role color + 'name' => $role['name'], + 'display_name' => $role['display_name'] ?? $role['name'], + 'custom_badge' => $role['custom_badge'] ?? null, + 'color' => $role['color'] ?? null, ]; } } diff --git a/backend/app/Controllers/User/User/SessionController.php b/backend/app/Controllers/User/User/SessionController.php index ccd2da9af..15e3450e5 100755 --- a/backend/app/Controllers/User/User/SessionController.php +++ b/backend/app/Controllers/User/User/SessionController.php @@ -21,9 +21,11 @@ use App\Chat\Role; use App\Chat\User; use App\Chat\Ticket; +use App\Permissions; use App\Chat\Activity; use App\Chat\MailList; use App\Chat\ApiClient; +use App\Chat\MailQueue; use App\Chat\Permission; use App\Chat\TicketStatus; use App\Chat\TicketMessage; @@ -36,6 +38,7 @@ use OpenApi\Attributes as OA; use App\Helpers\CaptchaHelper; use App\Config\ConfigInterface; +use App\Helpers\PermissionHelper; use App\Middleware\AuthMiddleware; use App\CloudFlare\CloudFlareRealIP; use App\Helpers\EmailDomainValidator; @@ -338,6 +341,7 @@ public function get(Request $request): Response $user['role'] = [ 'name' => $role ? ($role['name'] ?? $roleId) : $roleId, 'display_name' => $role ? ($role['display_name'] ?? 'User') : 'User', + 'custom_badge' => $role ? ($role['custom_badge'] ?? null) : null, 'color' => $role ? ($role['color'] ?? '#666666') : '#666666', ]; @@ -348,11 +352,24 @@ public function get(Request $request): Response $user['last_ip'] = $app->getIPIntoFBIFormat(); } - return ApiResponse::success([ + $sessionData = [ 'user_info' => $user, 'permissions' => $permissions, 'preferences' => [], - ], 'Session retrieved', 200); + ]; + + if ( + PermissionHelper::hasPermission($user['uuid'], Permissions::ADMIN_TICKETS_VIEW) + && $app->getConfig()->getSetting(ConfigInterface::TICKET_SYSTEM_ENABLED, 'false') === 'true' + ) { + $openCount = Ticket::getGlobalOpenTicketsCount(); + $sessionData['admin_ticket_stats'] = [ + 'open_count' => $openCount, + 'has_open_tickets' => $openCount > 0, + ]; + } + + return ApiResponse::success($sessionData, 'Session retrieved', 200); } #[OA\Post( @@ -943,6 +960,66 @@ public function getMails(Request $request): Response ], 'Mails retrieved successfully', 200); } + #[OA\Post( + path: '/api/user/mails/{id}/resend', + summary: 'Resend a failed email', + description: 'Re-queue a failed system email for delivery to the current user.', + tags: ['User - Session'], + parameters: [ + new OA\Parameter( + name: 'id', + in: 'path', + description: 'Mail list entry ID', + required: true, + schema: new OA\Schema(type: 'integer', minimum: 1) + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Email queued for resend'), + new OA\Response(response: 400, description: 'Bad request - Mail cannot be resent or SMTP disabled'), + new OA\Response(response: 401, description: 'Unauthorized - User not authenticated'), + new OA\Response(response: 404, description: 'Mail not found'), + new OA\Response(response: 500, description: 'Internal server error - Failed to resend mail'), + ] + )] + public function resendMail(Request $request, int $mailId): Response + { + $user = AuthMiddleware::getCurrentUser($request); + if ($user == null) { + return ApiResponse::error('You are not allowed to access this resource!', 'INVALID_ACCOUNT_TOKEN', 400, []); + } + + if ($mailId <= 0) { + return ApiResponse::error('Invalid mail ID', 'INVALID_MAIL_ID', 400); + } + + $mailListEntry = MailList::getById($mailId); + if (!$mailListEntry || ($mailListEntry['user_uuid'] ?? '') !== $user['uuid']) { + return ApiResponse::error('Mail not found', 'MAIL_NOT_FOUND', 404); + } + + $queueId = (int) ($mailListEntry['queue_id'] ?? 0); + $queueEntry = MailQueue::getById($queueId); + if (!$queueEntry) { + return ApiResponse::error('Mail not found', 'MAIL_NOT_FOUND', 404); + } + + if (($queueEntry['status'] ?? '') !== 'failed') { + return ApiResponse::error('Only failed emails can be resent', 'MAIL_NOT_RESENDABLE', 400); + } + + $app = App::getInstance(true); + if ($app->getConfig()->getSetting(ConfigInterface::SMTP_ENABLED, 'false') !== 'true') { + return ApiResponse::error('SMTP is not enabled', 'SMTP_DISABLED', 400); + } + + if (!MailQueue::retry($queueId)) { + return ApiResponse::error('Failed to queue email for resend', 'FAILED_TO_RESEND_MAIL', 500); + } + + return ApiResponse::success([], 'Email queued for resend', 200); + } + #[OA\Get( path: '/api/user/activities', summary: 'Get user activities with pagination', diff --git a/backend/app/Controllers/User/Vds/VmUserBackupController.php b/backend/app/Controllers/User/Vds/VmUserBackupController.php index afc53fa0d..0c91790d4 100644 --- a/backend/app/Controllers/User/Vds/VmUserBackupController.php +++ b/backend/app/Controllers/User/Vds/VmUserBackupController.php @@ -22,6 +22,7 @@ use App\Chat\VmTask; use App\Chat\VmInstance; use App\Helpers\VmGateway; +use App\Helpers\TimeHelper; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\VmInstanceBackup; @@ -78,6 +79,7 @@ public function listBackups(Request $request, int $id): Response } $backups = VmInstanceBackup::getBackupsByInstanceId((int) $vmInstance['id']); + $backups = TimeHelper::normaliseRows($backups); $storages = []; $vmNode = VmNode::getVmNodeById((int) $vmInstance['vm_node_id']); if ($vmNode) { diff --git a/backend/app/Controllers/Wings/Server/WingsServerInfoController.php b/backend/app/Controllers/Wings/Server/WingsServerInfoController.php index 9adcb674f..3996e5a81 100755 --- a/backend/app/Controllers/Wings/Server/WingsServerInfoController.php +++ b/backend/app/Controllers/Wings/Server/WingsServerInfoController.php @@ -242,16 +242,10 @@ public function getServer(Request $request, string $uuid): Response // Parse spell docker images if available (from spell.docker_images JSON field) $dockerImage = $server['image']; // Use server.image as fallback - if (!empty($spell['docker_images'])) { - try { - $dockerImages = json_decode($spell['docker_images'], true); - if (is_array($dockerImages) && !empty($dockerImages)) { - // Use the first available image from spell or fallback to server image - $dockerImage = $dockerImages[0] ?? $server['image']; - } - } catch (\Exception $e) { - // If docker images parsing fails, use server image - $dockerImage = $server['image']; + if (trim((string) $dockerImage) === '') { + $resolvedImage = Spell::resolveDefaultDockerImage($spell); + if ($resolvedImage !== null) { + $dockerImage = $resolvedImage; } } diff --git a/backend/app/Controllers/Wings/Server/WingsServerInstallController.php b/backend/app/Controllers/Wings/Server/WingsServerInstallController.php index 09210dc82..4070d694d 100755 --- a/backend/app/Controllers/Wings/Server/WingsServerInstallController.php +++ b/backend/app/Controllers/Wings/Server/WingsServerInstallController.php @@ -115,14 +115,9 @@ public function getServerInstall(Request $request, string $uuid): Response } elseif (!empty($spell['script_container'])) { $containerImage = $spell['script_container']; } elseif (!empty($spell['docker_images'])) { - try { - $dockerImages = json_decode($spell['docker_images'], true); - if (is_array($dockerImages) && !empty($dockerImages)) { - // Use the first available image from spell or fallback to server image - $containerImage = $dockerImages[0] ?? $server['image']; - } - } catch (\Exception $e) { - // If docker images parsing fails, use server image + $resolvedImage = Spell::resolveDefaultDockerImage($spell); + if ($resolvedImage !== null) { + $containerImage = $resolvedImage; } } diff --git a/backend/app/Controllers/Wings/Server/WingsServerListController.php b/backend/app/Controllers/Wings/Server/WingsServerListController.php index 21166cdb1..95a4ed1eb 100755 --- a/backend/app/Controllers/Wings/Server/WingsServerListController.php +++ b/backend/app/Controllers/Wings/Server/WingsServerListController.php @@ -25,6 +25,7 @@ use App\Chat\Allocation; use App\Chat\ServerVariable; use App\Helpers\ApiResponse; +use App\Helpers\AppUrlHelper; use OpenApi\Attributes as OA; use App\Chat\ServerCustomVariable; use App\Helpers\WingsFileTrashConfig; @@ -205,14 +206,10 @@ public function getRemoteServers(Request $request): Response // Parse spell docker images if available $dockerImage = $server['image']; - if (!empty($spell['docker_images'])) { - try { - $dockerImages = json_decode($spell['docker_images'], true); - if (is_array($dockerImages) && !empty($dockerImages)) { - $dockerImage = $dockerImages[0] ?? $server['image']; - } - } catch (\Exception $e) { - // If docker images parsing fails, use server image + if (trim((string) $dockerImage) === '') { + $resolvedImage = Spell::resolveDefaultDockerImage($spell); + if ($resolvedImage !== null) { + $dockerImage = $resolvedImage; } } @@ -405,8 +402,8 @@ public function getRemoteServers(Request $request): Response $data[] = $serverConfig; } - // Build pagination links - $baseUrl = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . '/api/remote/servers'; + // Build pagination links from APP_URL — not the request host (often localhost behind proxies). + $baseUrl = AppUrlHelper::apiUrl('/remote/servers'); $links = [ 'first' => $baseUrl . '?page=1', 'last' => $baseUrl . '?page=' . max(1, $lastPage), diff --git a/backend/app/Controllers/Wings/WingsAdminController.php b/backend/app/Controllers/Wings/WingsAdminController.php index 6ee7cf498..d373d25dc 100755 --- a/backend/app/Controllers/Wings/WingsAdminController.php +++ b/backend/app/Controllers/Wings/WingsAdminController.php @@ -145,18 +145,6 @@ public function utilization(Request $request, int $id): Response $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $utilization = $wings->getSystem()->getSystemUtilization(); // Emit event @@ -355,18 +343,6 @@ public function getIps(Request $request, int $id): Response $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $ips = $wings->getSystem()->getSystemIPs(); // Emit event @@ -452,18 +428,6 @@ public function system(Request $request, int $id): Response $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $system = $wings->getSystem()->getDetailedSystemInfo(); // Emit event @@ -527,18 +491,6 @@ public function listModules(Request $request, int $id): Response $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $modules = $wings->getModule()->listModules(); return ApiResponse::success($modules, 'Modules retrieved successfully', 200); @@ -596,18 +548,6 @@ public function getModuleConfig(Request $request, int $id, string $module): Resp $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $config = $wings->getModule()->getModuleConfig($module); return ApiResponse::success($config, 'Module configuration retrieved successfully', 200); @@ -670,18 +610,6 @@ public function updateModuleConfig(Request $request, int $id, string $module): R $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $config = $wings->getModule()->updateModuleConfig($module, $requestData['config']); return ApiResponse::success($config, 'Module configuration updated successfully', 200); @@ -739,18 +667,6 @@ public function enableModule(Request $request, int $id, string $module): Respons $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $result = $wings->getModule()->enableModule($module); return ApiResponse::success($result, 'Module enabled successfully', 200); @@ -808,18 +724,6 @@ public function disableModule(Request $request, int $id, string $module): Respon $timeout ); - if (APP_DEBUG) { - $wings->testConnection(); - } else { - try { - if (!$wings->testConnection()) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } catch (\Exception $e) { - return ApiResponse::error('Failed to connect to Wings', 'WINGS_CONNECTION_FAILED', 500); - } - } - $result = $wings->getModule()->disableModule($module); return ApiResponse::success($result, 'Module disabled successfully', 200); diff --git a/backend/app/Helpers/AppUrlHelper.php b/backend/app/Helpers/AppUrlHelper.php new file mode 100644 index 000000000..06d22b76a --- /dev/null +++ b/backend/app/Helpers/AppUrlHelper.php @@ -0,0 +1,49 @@ +. + */ + +namespace App\Helpers; + +use App\App; +use App\Config\ConfigInterface; + +/** + * Builds public panel URLs from APP_URL instead of the inbound request host. + * + * Required for Wings/daemon clients behind reverse proxies where PHP may see localhost. + */ +class AppUrlHelper +{ + public static function baseUrl(): string + { + $appUrl = App::getInstance(true)->getConfig()->getSetting( + ConfigInterface::APP_URL, + 'https://featherpanel.mythical.systems' + ); + + return rtrim((string) $appUrl, '/'); + } + + public static function apiUrl(string $path): string + { + $path = '/' . ltrim($path, '/'); + if (!str_starts_with($path, '/api/')) { + $path = '/api' . $path; + } + + return self::baseUrl() . $path; + } +} diff --git a/backend/app/Helpers/AvatarHelper.php b/backend/app/Helpers/AvatarHelper.php new file mode 100644 index 000000000..69453c556 --- /dev/null +++ b/backend/app/Helpers/AvatarHelper.php @@ -0,0 +1,225 @@ +. + */ + +namespace App\Helpers; + +use App\App; +use Gravatar\Gravatar; +use App\Config\ConfigInterface; + +class AvatarHelper +{ + public const PROVIDER_GRAVATAR = 'gravatar'; + public const PROVIDER_PANEL_LOGO = 'panel_logo'; + public const PROVIDER_UI_AVATARS = 'ui_avatars'; + public const PROVIDER_ROBOHASH = 'robohash'; + public const PROVIDER_DICEBEAR = 'dicebear'; + public const PROVIDER_CUSTOM = 'custom'; + + /** + * @return string[] + */ + public static function getProviders(): array + { + return [ + self::PROVIDER_GRAVATAR, + self::PROVIDER_PANEL_LOGO, + self::PROVIDER_UI_AVATARS, + self::PROVIDER_ROBOHASH, + self::PROVIDER_DICEBEAR, + self::PROVIDER_CUSTOM, + ]; + } + + public static function resolveAvatar( + ?string $avatar, + string $email, + ?string $username = null, + ?string $firstName = null, + ?string $lastName = null, + ): string { + if (!self::isDefaultAvatar($avatar)) { + return (string) $avatar; + } + + return self::getDefaultAvatarUrl($email, $username, $firstName, $lastName); + } + + public static function isDefaultAvatar(?string $avatar): bool + { + $avatar = trim((string) $avatar); + if ($avatar === '') { + return true; + } + + $config = App::getInstance(true)->getConfig(); + $knownDefaults = [ + 'https://github.com/featherpanel-com.png', + $config->getSetting(ConfigInterface::APP_LOGO_WHITE, ''), + $config->getSetting(ConfigInterface::APP_LOGO_DARK, ''), + ]; + + foreach ($knownDefaults as $default) { + if ($default !== '' && $avatar === $default) { + return true; + } + } + + return false; + } + + public static function getDefaultAvatarUrl( + string $email, + ?string $username = null, + ?string $firstName = null, + ?string $lastName = null, + ): string { + $config = App::getInstance(true)->getConfig(); + $provider = strtolower(trim($config->getSetting(ConfigInterface::AVATAR_PROVIDER, self::PROVIDER_GRAVATAR))); + + switch ($provider) { + case self::PROVIDER_PANEL_LOGO: + return $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png'); + + case self::PROVIDER_UI_AVATARS: + $name = self::buildDisplayName($username, $firstName, $lastName, $email); + + return 'https://ui-avatars.com/api/?' . http_build_query([ + 'name' => $name, + 'background' => 'random', + 'size' => 256, + ]); + + case self::PROVIDER_ROBOHASH: + return 'https://robohash.org/' . rawurlencode($email) . '.png?size=256x256'; + + case self::PROVIDER_DICEBEAR: + return 'https://api.dicebear.com/9.x/initials/svg?seed=' . rawurlencode($email); + + case self::PROVIDER_CUSTOM: + $template = trim($config->getSetting(ConfigInterface::AVATAR_CUSTOM_URL, '')); + if ($template === '') { + return self::buildGravatarUrl($email, $username, $firstName, $lastName); + } + + return self::applyCustomTemplate($template, $email, $username, $firstName, $lastName); + + case self::PROVIDER_GRAVATAR: + default: + return self::buildGravatarUrl($email, $username, $firstName, $lastName); + } + } + + /** + * @param array|null $user + * + * @return array|null + */ + public static function enrichUser(?array $user): ?array + { + if ($user === null) { + return null; + } + + if (array_key_exists('avatar', $user) && isset($user['email']) && is_string($user['email'])) { + $user['avatar'] = self::resolveAvatar( + isset($user['avatar']) ? (string) $user['avatar'] : null, + $user['email'], + isset($user['username']) ? (string) $user['username'] : null, + isset($user['first_name']) ? (string) $user['first_name'] : null, + isset($user['last_name']) ? (string) $user['last_name'] : null, + ); + } + + return $user; + } + + /** + * @param array> $users + * + * @return array> + */ + public static function enrichUsers(array $users): array + { + return array_map( + static fn (array $user): array => self::enrichUser($user) ?? $user, + $users, + ); + } + + private static function buildGravatarUrl( + string $email, + ?string $username, + ?string $firstName, + ?string $lastName, + ): string { + try { + $gravatar = new Gravatar(['s' => 256, 'd' => 'mp']); + + return $gravatar->avatar($email); + } catch (\InvalidArgumentException) { + $name = self::buildDisplayName($username, $firstName, $lastName, $email); + + return 'https://ui-avatars.com/api/?' . http_build_query([ + 'name' => $name, + 'size' => 256, + ]); + } + } + + private static function applyCustomTemplate( + string $template, + string $email, + ?string $username, + ?string $firstName, + ?string $lastName, + ): string { + $config = App::getInstance(true)->getConfig(); + $appUrl = rtrim($config->getSetting(ConfigInterface::APP_URL, ''), '/'); + $emailHash = md5(strtolower(trim($email))); + $name = self::buildDisplayName($username, $firstName, $lastName, $email); + + $replacements = [ + '{email}' => $email, + '{username}' => (string) $username, + '{name}' => $name, + '{hash}' => $emailHash, + '{email_hash}' => $emailHash, + '{app_url}' => $appUrl, + ]; + + return str_replace(array_keys($replacements), array_values($replacements), $template); + } + + private static function buildDisplayName( + ?string $username, + ?string $firstName, + ?string $lastName, + string $email, + ): string { + $parts = array_filter([trim((string) $firstName), trim((string) $lastName)]); + if ($parts !== []) { + return implode(' ', $parts); + } + + if ($username !== null && trim($username) !== '') { + return trim($username); + } + + return explode('@', $email)[0]; + } +} diff --git a/backend/app/Helpers/PhpMyAdmin.php b/backend/app/Helpers/PhpMyAdmin.php index 37b15f32d..86c9ccaa4 100644 --- a/backend/app/Helpers/PhpMyAdmin.php +++ b/backend/app/Helpers/PhpMyAdmin.php @@ -466,7 +466,7 @@ private static function installThemes(string $pmaPath, $logger): void private static function copyTokenFiles(string $pmaPath, $logger): void { $sourceDir = dirname(__DIR__, 2) . '/storage/modules/pma'; - $tokenFiles = ['token.php', 'token-logout.php']; + $tokenFiles = ['token.php', 'token-logout.php', 'auth-page.php']; // Check if source directory exists if (!is_dir($sourceDir)) { diff --git a/backend/app/Helpers/UserDeviceRegistrationGuard.php b/backend/app/Helpers/UserDeviceRegistrationGuard.php new file mode 100644 index 000000000..04179d0a5 --- /dev/null +++ b/backend/app/Helpers/UserDeviceRegistrationGuard.php @@ -0,0 +1,69 @@ +. + */ + +namespace App\Helpers; + +use App\Chat\UserDevice; +use App\Config\ConfigFactory; +use App\Config\ConfigInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class UserDeviceRegistrationGuard +{ + public static function assertRegistrationAllowed(Request $request, ConfigFactory $config): ?Response + { + if ($config->getSetting(ConfigInterface::REGISTRATION_DEVICE_LIMIT_ENABLED, 'false') !== 'true') { + return null; + } + + $maxAccounts = max(1, (int) $config->getSetting(ConfigInterface::REGISTRATION_DEVICE_MAX_ACCOUNTS, '1')); + + $clientToken = UserDeviceTracker::extractClientToken($request); + if ($clientToken === null) { + return null; + } + + $deviceHash = UserDevice::hashClientToken($clientToken); + $accountCount = UserDevice::countDistinctUsersByDeviceHash($deviceHash); + + if ($accountCount < $maxAccounts) { + return null; + } + + $mainAccount = UserDevice::getMainAccountForDeviceHash($deviceHash); + $supportUrl = trim((string) $config->getSetting(ConfigInterface::APP_SUPPORT_URL, '')); + + if ($mainAccount !== null) { + $message = sprintf( + 'Too many accounts on this device. Please use your main account: %s, or contact support.', + $mainAccount['username'] + ); + } else { + $message = 'Too many accounts on this device. Please use your main account or contact support.'; + } + + return ApiResponse::error($message, 'DEVICE_ACCOUNT_LIMIT', 403, [ + 'main_account' => $mainAccount !== null ? [ + 'uuid' => $mainAccount['uuid'], + 'username' => $mainAccount['username'], + ] : null, + 'support_url' => $supportUrl !== '' ? $supportUrl : null, + 'max_accounts' => $maxAccounts, + ]); + } +} diff --git a/backend/app/Helpers/UserDeviceTracker.php b/backend/app/Helpers/UserDeviceTracker.php new file mode 100644 index 000000000..0c263f25e --- /dev/null +++ b/backend/app/Helpers/UserDeviceTracker.php @@ -0,0 +1,135 @@ +. + */ + +namespace App\Helpers; + +use App\Chat\UserDevice; +use App\CloudFlare\CloudFlareRealIP; +use Symfony\Component\HttpFoundation\Request; + +class UserDeviceTracker +{ + public const CLIENT_COOKIE_NAME = '_fp_ui_sid'; + + public const CLIENT_HEADER_SYNC = 'X-FP-UI-Sync'; + + public const CLIENT_HEADER_META = 'X-FP-UI-Meta'; + + public static function trackFromRequest(Request $request, array $user): void + { + $clientToken = self::extractClientToken($request); + if ($clientToken === null) { + return; + } + + UserDevice::trackVisit( + $user['uuid'], + $clientToken, + self::extractSignals($request), + CloudFlareRealIP::getRealIP(), + $request->headers->get('User-Agent'), + ); + } + + public static function extractClientToken(Request $request): ?string + { + $header = trim((string) $request->headers->get(self::CLIENT_HEADER_SYNC, '')); + if ($header !== '' && preg_match('/^[a-f0-9\-]{16,64}$/i', $header)) { + return $header; + } + + $cookie = trim((string) ($request->cookies->get(self::CLIENT_COOKIE_NAME) ?? '')); + if ($cookie !== '' && preg_match('/^[a-f0-9\-]{16,64}$/i', $cookie)) { + return $cookie; + } + + return null; + } + + /** + * @return array|null + */ + public static function extractSignals(Request $request): ?array + { + $metaHeader = trim((string) $request->headers->get(self::CLIENT_HEADER_META, '')); + if ($metaHeader === '') { + return null; + } + + $decoded = base64_decode(strtr($metaHeader, '-_', '+/'), true); + if ($decoded === false) { + return null; + } + + $signals = json_decode($decoded, true); + if (!is_array($signals) || empty($signals)) { + return null; + } + + $allowed = ['tz', 'lang', 'sw', 'sh', 'cd', 'dm', 'hc']; + $filtered = []; + foreach ($allowed as $key) { + if (!array_key_exists($key, $signals)) { + continue; + } + $value = $signals[$key]; + if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) { + $filtered[$key] = $value; + } + } + + return empty($filtered) ? null : $filtered; + } + + public static function trackFromGlobals(array $user): void + { + $clientToken = null; + $header = trim((string) ($_SERVER['HTTP_X_FP_UI_SYNC'] ?? '')); + if ($header !== '' && preg_match('/^[a-f0-9\-]{16,64}$/i', $header)) { + $clientToken = $header; + } else { + $cookie = trim((string) ($_COOKIE[self::CLIENT_COOKIE_NAME] ?? '')); + if ($cookie !== '' && preg_match('/^[a-f0-9\-]{16,64}$/i', $cookie)) { + $clientToken = $cookie; + } + } + + if ($clientToken === null) { + return; + } + + $signals = null; + $metaHeader = trim((string) ($_SERVER['HTTP_X_FP_UI_META'] ?? '')); + if ($metaHeader !== '') { + $decoded = base64_decode(strtr($metaHeader, '-_', '+/'), true); + if ($decoded !== false) { + $parsed = json_decode($decoded, true); + if (is_array($parsed) && !empty($parsed)) { + $signals = $parsed; + } + } + } + + UserDevice::trackVisit( + $user['uuid'], + $clientToken, + is_array($signals) ? $signals : null, + CloudFlareRealIP::getRealIP(), + $_SERVER['HTTP_USER_AGENT'] ?? null, + ); + } +} diff --git a/backend/app/Helpers/WingsUrlHelper.php b/backend/app/Helpers/WingsUrlHelper.php new file mode 100644 index 000000000..b2378b331 --- /dev/null +++ b/backend/app/Helpers/WingsUrlHelper.php @@ -0,0 +1,69 @@ +. + */ + +namespace App\Helpers; + +/** + * Builds Wings base URLs respecting the node's reverse-proxy configuration. + */ +class WingsUrlHelper +{ + /** + * Whether the node is configured to sit behind a reverse proxy (nginx, etc.). + */ + public static function isBehindProxy(array $node): bool + { + return filter_var($node['behind_proxy'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Build the Wings API base URL for a node row. + */ + public static function buildFromNode(array $node): string + { + return self::buildBaseUrl( + (string) ($node['scheme'] ?? 'http'), + (string) ($node['fqdn'] ?? 'localhost'), + (int) ($node['daemonListen'] ?? 8443), + self::isBehindProxy($node), + ); + } + + /** + * Build the Wings API base URL. + * + * When behind a reverse proxy, omit the port so standard 443/80 is used. + */ + public static function buildBaseUrl(string $scheme, string $host, int $port, bool $behindProxy = false): string + { + $host = rtrim($host, '/'); + + if ($behindProxy) { + return "{$scheme}://{$host}"; + } + + return "{$scheme}://{$host}:{$port}"; + } + + /** + * Convert an HTTP(S) Wings base URL to the matching WS(S) base URL. + */ + public static function toWebSocketBaseUrl(string $httpBaseUrl): string + { + return str_replace(['https://', 'http://'], ['wss://', 'ws://'], $httpBaseUrl); + } +} diff --git a/backend/app/Mail/templates/TicketAdminAlert.php b/backend/app/Mail/templates/TicketAdminAlert.php new file mode 100644 index 000000000..34e5c0723 --- /dev/null +++ b/backend/app/Mail/templates/TicketAdminAlert.php @@ -0,0 +1,131 @@ +. + */ + +namespace App\Mail\templates; + +use App\Chat\MailList; +use App\Chat\MailQueue; +use App\Chat\MailTemplate; + +class TicketAdminAlert +{ + public static function getTemplate(array $data): string + { + $required = [ + 'app_name', 'app_url', 'first_name', 'last_name', 'email', 'username', + 'app_support_url', 'ticket_title', 'ticket_url', 'ticket_owner', 'event', 'open_tickets_count', + ]; + foreach ($required as $field) { + if (!isset($data[$field])) { + return ''; + } + } + + $row = MailTemplate::getByName('ticket_admin_alert'); + if ($row === null || ($row['body'] ?? '') === '') { + return ''; + } + + return self::parseTemplate($row['body'], self::placeholderData($data)); + } + + public static function parseTemplate(string $template, array $data): string + { + foreach ($data as $key => $value) { + $template = str_replace('{' . $key . '}', (string) $value, $template); + } + + return $template; + } + + public static function send(array $data): void + { + $required = [ + 'email', 'app_name', 'app_url', 'first_name', 'last_name', 'username', + 'app_support_url', 'uuid', 'enabled', 'ticket_title', 'ticket_url', 'ticket_owner', 'event', + ]; + foreach ($required as $field) { + if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) { + return; + } + } + + if ($data['enabled'] !== 'true') { + return; + } + + $template = self::getTemplate($data); + $subject = self::getSubject($data); + if ($template === '' || $subject === '') { + return; + } + + $id = MailQueue::create([ + 'user_uuid' => $data['uuid'], + 'subject' => $subject, + 'body' => $template, + ]); + + if ($id === false || $id === true) { + return; + } + + MailList::create([ + 'queue_id' => $id, + 'user_uuid' => $data['uuid'], + ]); + } + + private static function getSubject(array $data): string + { + $row = MailTemplate::getByName('ticket_admin_alert'); + $subjectTemplate = $row['subject'] ?? ''; + if ($subjectTemplate === '') { + return $data['event'] === 'user_reply' + ? '[' . $data['app_name'] . '] New reply on support ticket' + : '[' . $data['app_name'] . '] New support ticket'; + } + + return self::parseTemplate($subjectTemplate, self::placeholderData($data)); + } + + /** + * @return array + */ + private static function placeholderData(array $data): array + { + $eventLabel = ($data['event'] ?? '') === 'user_reply' + ? 'A user replied to a support ticket' + : 'A new support ticket was opened'; + + return [ + 'app_name' => $data['app_name'], + 'app_url' => $data['app_url'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'username' => $data['username'], + 'dashboard_url' => $data['app_url'] . '/dashboard', + 'support_url' => $data['app_support_url'], + 'ticket_title' => htmlspecialchars((string) $data['ticket_title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + 'ticket_url' => htmlspecialchars((string) $data['ticket_url'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + 'ticket_owner' => htmlspecialchars((string) $data['ticket_owner'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + 'event_label' => $eventLabel, + 'open_tickets_count' => htmlspecialchars((string) ($data['open_tickets_count'] ?? '0'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ]; + } +} diff --git a/backend/app/Mail/templates/TicketClosed.php b/backend/app/Mail/templates/TicketClosed.php new file mode 100644 index 000000000..ada71e8bf --- /dev/null +++ b/backend/app/Mail/templates/TicketClosed.php @@ -0,0 +1,130 @@ +. + */ + +namespace App\Mail\templates; + +use App\Chat\MailList; +use App\Chat\MailQueue; +use App\Chat\MailTemplate; + +class TicketClosed +{ + public static function getTemplate(array $data): string + { + if (!self::hasRequiredFields($data)) { + return ''; + } + + return self::parseTemplate(MailTemplate::getByName('ticket_closed')['body'] ?? '', self::templateData($data)); + } + + public static function parseTemplate(string $template, array $data): string + { + foreach ($data as $key => $value) { + $template = str_replace('{' . $key . '}', (string) $value, $template); + } + + return $template; + } + + public static function send(array $data): void + { + if (!self::hasRequiredFields($data)) { + return; + } + + if ($data['enabled'] === 'false') { + return; + } + + $template = self::getTemplate($data); + $subject = self::getSubject($data); + if ($template === '' || $subject === '') { + return; + } + + $id = MailQueue::create([ + 'user_uuid' => $data['uuid'], + 'subject' => $subject, + 'body' => $template, + ]); + + if ($id === false || $id === true) { + return; + } + + MailList::create([ + 'queue_id' => $id, + 'user_uuid' => $data['uuid'], + ]); + } + + private static function getSubject(array $data): string + { + $row = MailTemplate::getByName('ticket_closed'); + $subjectTemplate = $row['subject'] ?? ''; + if ($subjectTemplate === '') { + return $data['subject'] ?? ''; + } + + return self::parseTemplate($subjectTemplate, self::templateData($data)); + } + + /** + * @return array + */ + private static function templateData(array $data): array + { + return [ + 'app_name' => $data['app_name'], + 'app_url' => $data['app_url'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'username' => $data['username'], + 'dashboard_url' => $data['dashboard_url'] ?? ($data['app_url'] . '/dashboard'), + 'support_url' => $data['support_url'] ?? $data['app_support_url'], + 'ticket_title' => $data['ticket_title'], + 'ticket_url' => $data['ticket_url'], + ]; + } + + private static function hasRequiredFields(array $data): bool + { + $required = [ + 'email', + 'app_name', + 'app_url', + 'first_name', + 'last_name', + 'username', + 'app_support_url', + 'uuid', + 'enabled', + 'ticket_title', + 'ticket_url', + ]; + + foreach ($required as $field) { + if (!isset($data[$field]) || trim((string) $data[$field]) === '') { + return false; + } + } + + return true; + } +} diff --git a/backend/app/Mail/templates/TicketReopened.php b/backend/app/Mail/templates/TicketReopened.php new file mode 100644 index 000000000..c84674c0b --- /dev/null +++ b/backend/app/Mail/templates/TicketReopened.php @@ -0,0 +1,130 @@ +. + */ + +namespace App\Mail\templates; + +use App\Chat\MailList; +use App\Chat\MailQueue; +use App\Chat\MailTemplate; + +class TicketReopened +{ + public static function getTemplate(array $data): string + { + if (!self::hasRequiredFields($data)) { + return ''; + } + + return self::parseTemplate(MailTemplate::getByName('ticket_reopened')['body'] ?? '', self::templateData($data)); + } + + public static function parseTemplate(string $template, array $data): string + { + foreach ($data as $key => $value) { + $template = str_replace('{' . $key . '}', (string) $value, $template); + } + + return $template; + } + + public static function send(array $data): void + { + if (!self::hasRequiredFields($data)) { + return; + } + + if ($data['enabled'] === 'false') { + return; + } + + $template = self::getTemplate($data); + $subject = self::getSubject($data); + if ($template === '' || $subject === '') { + return; + } + + $id = MailQueue::create([ + 'user_uuid' => $data['uuid'], + 'subject' => $subject, + 'body' => $template, + ]); + + if ($id === false || $id === true) { + return; + } + + MailList::create([ + 'queue_id' => $id, + 'user_uuid' => $data['uuid'], + ]); + } + + private static function getSubject(array $data): string + { + $row = MailTemplate::getByName('ticket_reopened'); + $subjectTemplate = $row['subject'] ?? ''; + if ($subjectTemplate === '') { + return $data['subject'] ?? ''; + } + + return self::parseTemplate($subjectTemplate, self::templateData($data)); + } + + /** + * @return array + */ + private static function templateData(array $data): array + { + return [ + 'app_name' => $data['app_name'], + 'app_url' => $data['app_url'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'username' => $data['username'], + 'dashboard_url' => $data['dashboard_url'] ?? ($data['app_url'] . '/dashboard'), + 'support_url' => $data['support_url'] ?? $data['app_support_url'], + 'ticket_title' => $data['ticket_title'], + 'ticket_url' => $data['ticket_url'], + ]; + } + + private static function hasRequiredFields(array $data): bool + { + $required = [ + 'email', + 'app_name', + 'app_url', + 'first_name', + 'last_name', + 'username', + 'app_support_url', + 'uuid', + 'enabled', + 'ticket_title', + 'ticket_url', + ]; + + foreach ($required as $field) { + if (!isset($data[$field]) || trim((string) $data[$field]) === '') { + return false; + } + } + + return true; + } +} diff --git a/backend/app/Mail/templates/TicketReplied.php b/backend/app/Mail/templates/TicketReplied.php new file mode 100644 index 000000000..e5d0df1a3 --- /dev/null +++ b/backend/app/Mail/templates/TicketReplied.php @@ -0,0 +1,132 @@ +. + */ + +namespace App\Mail\templates; + +use App\Chat\MailList; +use App\Chat\MailQueue; +use App\Chat\MailTemplate; + +class TicketReplied +{ + public static function getTemplate(array $data): string + { + if (!self::hasRequiredFields($data)) { + return ''; + } + + return self::parseTemplate(MailTemplate::getByName('ticket_replied')['body'] ?? '', self::templateData($data)); + } + + public static function parseTemplate(string $template, array $data): string + { + foreach ($data as $key => $value) { + $template = str_replace('{' . $key . '}', (string) $value, $template); + } + + return $template; + } + + public static function send(array $data): void + { + if (!self::hasRequiredFields($data) || !isset($data['reply_preview']) || !isset($data['replier_name'])) { + return; + } + + if ($data['enabled'] === 'false') { + return; + } + + $template = self::getTemplate($data); + $subject = self::getSubject($data); + if ($template === '' || $subject === '') { + return; + } + + $id = MailQueue::create([ + 'user_uuid' => $data['uuid'], + 'subject' => $subject, + 'body' => $template, + ]); + + if ($id === false || $id === true) { + return; + } + + MailList::create([ + 'queue_id' => $id, + 'user_uuid' => $data['uuid'], + ]); + } + + private static function getSubject(array $data): string + { + $row = MailTemplate::getByName('ticket_replied'); + $subjectTemplate = $row['subject'] ?? ''; + if ($subjectTemplate === '') { + return $data['subject'] ?? ''; + } + + return self::parseTemplate($subjectTemplate, self::templateData($data)); + } + + /** + * @return array + */ + private static function templateData(array $data): array + { + return [ + 'app_name' => $data['app_name'], + 'app_url' => $data['app_url'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'username' => $data['username'], + 'dashboard_url' => $data['dashboard_url'] ?? ($data['app_url'] . '/dashboard'), + 'support_url' => $data['support_url'] ?? $data['app_support_url'], + 'ticket_title' => $data['ticket_title'], + 'ticket_url' => $data['ticket_url'], + 'reply_preview' => $data['reply_preview'], + 'replier_name' => $data['replier_name'], + ]; + } + + private static function hasRequiredFields(array $data): bool + { + $required = [ + 'email', + 'app_name', + 'app_url', + 'first_name', + 'last_name', + 'username', + 'app_support_url', + 'uuid', + 'enabled', + 'ticket_title', + 'ticket_url', + ]; + + foreach ($required as $field) { + if (!isset($data[$field]) || trim((string) $data[$field]) === '') { + return false; + } + } + + return true; + } +} diff --git a/backend/app/Middleware/AuthMiddleware.php b/backend/app/Middleware/AuthMiddleware.php index 20b86e9ee..16266aeb3 100755 --- a/backend/app/Middleware/AuthMiddleware.php +++ b/backend/app/Middleware/AuthMiddleware.php @@ -21,6 +21,7 @@ use App\Chat\ApiClient; use App\Helpers\ApiResponse; use App\Helpers\IpAddressMatcher; +use App\Helpers\UserDeviceTracker; use App\CloudFlare\CloudFlareRealIP; use App\Helpers\ApiClientForeignIpNotifier; use Symfony\Component\HttpFoundation\Request; @@ -41,6 +42,7 @@ public function handle(Request $request, callable $next): Response } User::updateUser($userInfo['uuid'], ['last_ip' => CloudFlareRealIP::getRealIP()]); + UserDeviceTracker::trackFromRequest($request, $userInfo); // Attach user info to the request attributes for downstream use $request->attributes->set('user', $userInfo); $request->attributes->set('auth_type', 'session'); diff --git a/backend/app/Plugins/PluginEntryValidator.php b/backend/app/Plugins/PluginEntryValidator.php new file mode 100644 index 000000000..a54bb7cdd --- /dev/null +++ b/backend/app/Plugins/PluginEntryValidator.php @@ -0,0 +1,341 @@ +. + */ + +namespace App\Plugins; + +use Symfony\Component\Yaml\Yaml; +use Symfony\Component\Yaml\Exception\ParseException; + +class PluginEntryValidator +{ + /** + * Validate an extracted addon package before it is installed. + * + * @param string $packageDir Path to the extracted addon directory (must contain conf.yml) + * @param string|null $identifier Optional expected identifier (e.g. from cloud registry) + * + * @return array{valid: bool, errors: string[], identifier: string|null, entry_class: string|null} + */ + public static function validatePackage(string $packageDir, ?string $identifier = null): array + { + $errors = []; + $packageDir = rtrim($packageDir, '/'); + $configFile = $packageDir . '/conf.yml'; + + if (!file_exists($configFile)) { + return self::result(false, ['Missing conf.yml'], null, null); + } + + try { + $conf = Yaml::parseFile($configFile); + } catch (ParseException $e) { + return self::result(false, ['Failed to parse conf.yml: ' . $e->getMessage()], null, null); + } catch (\Throwable $e) { + return self::result(false, ['Failed to read conf.yml: ' . $e->getMessage()], null, null); + } + + if (!is_array($conf) || !PluginConfig::isConfigValid($conf)) { + return self::result(false, ['Invalid or incomplete plugin configuration in conf.yml'], null, null); + } + + $confIdentifier = (string) ($conf['plugin']['identifier'] ?? ''); + if ($identifier !== null && $confIdentifier !== $identifier) { + $errors[] = 'conf.yml identifier "' . $confIdentifier . '" does not match expected "' . $identifier . '"'; + } + + $resolvedIdentifier = $identifier ?? $confIdentifier; + if ($resolvedIdentifier === '' || !PluginConfig::isValidIdentifier($resolvedIdentifier)) { + $errors[] = 'Invalid plugin identifier in conf.yml'; + } + + $entryName = (string) ($conf['plugin']['name'] ?? ''); + if ($entryName === '') { + $errors[] = 'plugin.name is required in conf.yml'; + } + + if (!empty($errors)) { + return self::result(false, $errors, $resolvedIdentifier, null); + } + + $expectedNamespace = 'App\\Addons\\' . $resolvedIdentifier; + $entryFile = $packageDir . '/' . $entryName . '.php'; + $resolvedEntryName = $entryName; + + if (!file_exists($entryFile)) { + $candidates = self::discoverEntryCandidates($packageDir, $resolvedIdentifier); + if (count($candidates) === 0) { + $errors[] = 'Entry class file "' . $entryName . '.php" not found in plugin root'; + } elseif (count($candidates) > 1) { + $errors[] = 'Multiple AppPlugin entry classes found in plugin root; set plugin.name in conf.yml to the correct class'; + } else { + $entryFile = $candidates[0]['file']; + $resolvedEntryName = $candidates[0]['class']; + } + } + + if (!empty($errors)) { + return self::result(false, $errors, $resolvedIdentifier, null); + } + + $parsed = self::parsePhpClassFile($entryFile); + if ($parsed === null) { + return self::result( + false, + ['Failed to parse entry class file: ' . basename($entryFile)], + $resolvedIdentifier, + null + ); + } + + if ($parsed['namespace'] !== $expectedNamespace) { + $errors[] = 'Entry class namespace "' . $parsed['namespace'] . '" does not match required "' . $expectedNamespace . '"'; + } + + if ($parsed['class'] !== $resolvedEntryName) { + $errors[] = 'Entry class name "' . $parsed['class'] . '" does not match plugin.name "' . $resolvedEntryName . '" in conf.yml'; + } + + if (!$parsed['implements_app_plugin']) { + $errors[] = 'Entry class "' . $resolvedEntryName . '" must implement App\\Plugins\\AppPlugin'; + } + + if (!empty($errors)) { + return self::result(false, $errors, $resolvedIdentifier, null); + } + + return self::result(true, [], $resolvedIdentifier, $expectedNamespace . '\\' . $resolvedEntryName); + } + + /** + * @param string[] $errors + * + * @return array{valid: bool, errors: string[], identifier: string|null, entry_class: string|null} + */ + private static function result(bool $valid, array $errors, ?string $identifier, ?string $entryClass): array + { + return [ + 'valid' => $valid, + 'errors' => $errors, + 'identifier' => $identifier, + 'entry_class' => $entryClass, + ]; + } + + /** + * @return array + */ + private static function discoverEntryCandidates(string $packageDir, string $identifier): array + { + $candidates = []; + + foreach (glob($packageDir . '/*.php') ?: [] as $file) { + $parsed = self::parsePhpClassFile($file); + if ($parsed === null || !$parsed['implements_app_plugin']) { + continue; + } + + $candidates[] = [ + 'file' => $file, + 'class' => $parsed['class'], + ]; + } + + return $candidates; + } + + /** + * @return array{namespace: string, class: string, implements_app_plugin: bool}|null + */ + private static function parsePhpClassFile(string $file): ?array + { + $content = @file_get_contents($file); + if ($content === false) { + return null; + } + + $tokens = token_get_all($content); + $namespace = ''; + $class = ''; + $implementsAppPlugin = false; + $tokenCount = count($tokens); + + for ($i = 0; $i < $tokenCount; ++$i) { + $token = $tokens[$i]; + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespace = self::readNamespace($tokens, $i + 1); + } + + if ($token[0] === T_CLASS && self::isClassDeclaration($tokens, $i)) { + $class = self::readNextIdentifier($tokens, $i + 1); + } + + if ($token[0] === T_IMPLEMENTS) { + foreach (self::readImplements($tokens, $i + 1) as $iface) { + if ($iface === 'AppPlugin' || str_ends_with($iface, '\\AppPlugin')) { + $implementsAppPlugin = true; + break; + } + } + } + } + + if ($class === '') { + return null; + } + + return [ + 'namespace' => $namespace, + 'class' => $class, + 'implements_app_plugin' => $implementsAppPlugin, + ]; + } + + /** + * @param array $tokens + */ + private static function isClassDeclaration(array $tokens, int $index): bool + { + for ($j = $index - 1; $j >= 0; --$j) { + $token = $tokens[$j]; + if (!is_array($token)) { + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return $token[0] !== T_NEW; + } + + return true; + } + + /** + * @param array $tokens + */ + private static function readNamespace(array $tokens, int $start): string + { + $parts = []; + $count = count($tokens); + + for ($i = $start; $i < $count; ++$i) { + $token = $tokens[$i]; + if (is_array($token) && $token[0] === T_NAME_QUALIFIED) { + return $token[1]; + } + if (is_array($token) && $token[0] === T_NS_SEPARATOR) { + if (!empty($parts) && !str_ends_with(end($parts), '\\')) { + $parts[] = '\\'; + } + + continue; + } + if (is_array($token) && in_array($token[0], [T_STRING, T_NAME_QUALIFIED], true)) { + $parts[] = $token[1]; + + continue; + } + if ($token === ';' || $token === '{') { + break; + } + if (is_array($token) && !in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + break; + } + } + + return implode('', $parts); + } + + /** + * @param array $tokens + */ + private static function readNextIdentifier(array $tokens, int $start): string + { + $count = count($tokens); + for ($i = $start; $i < $count; ++$i) { + $token = $tokens[$i]; + if (!is_array($token)) { + continue; + } + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + if (in_array($token[0], [T_STRING, T_NAME_QUALIFIED], true)) { + return $token[1]; + } + + break; + } + + return ''; + } + + /** + * @param array $tokens + * + * @return string[] + */ + private static function readImplements(array $tokens, int $start): array + { + $interfaces = []; + $current = ''; + $count = count($tokens); + + for ($i = $start; $i < $count; ++$i) { + $token = $tokens[$i]; + if ($token === '{') { + break; + } + if (is_array($token) && in_array($token[0], [T_STRING, T_NAME_QUALIFIED], true)) { + $current .= $token[1]; + + continue; + } + if (is_array($token) && $token[0] === T_NS_SEPARATOR) { + $current .= '\\'; + + continue; + } + if ($token === ',') { + if ($current !== '') { + $interfaces[] = $current; + } + $current = ''; + + continue; + } + if (is_array($token) && !in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + if ($current !== '') { + $interfaces[] = $current; + } + + break; + } + } + + if ($current !== '') { + $interfaces[] = $current; + } + + return $interfaces; + } +} diff --git a/backend/app/Plugins/PluginManager.php b/backend/app/Plugins/PluginManager.php index 53653f85b..39690e837 100755 --- a/backend/app/Plugins/PluginManager.php +++ b/backend/app/Plugins/PluginManager.php @@ -36,9 +36,13 @@ public function loadKernel(): void try { $pluginFiles = $this->getPluginFiles(); foreach ($pluginFiles as $plugin) { - $this->processPlugin($plugin, $eventManager); + try { + $this->processPlugin($plugin, $eventManager); + } catch (\Throwable $e) { + $this->logger->error('Failed to load plugin "' . $plugin . '": ' . $e->getMessage()); + } } - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->logger->error('Failed to start plugins: ' . $e->getMessage()); } } @@ -159,6 +163,15 @@ private function processPlugin(string $plugin, $eventManager): void return; } + $confIdentifier = (string) ($config['plugin']['identifier'] ?? ''); + if ($confIdentifier !== $plugin) { + $this->logger->warning( + 'Skipping plugin "' . $plugin . '": conf.yml identifier "' . $confIdentifier . '" does not match folder name' + ); + + return; + } + $this->validateAndLoadPlugin($plugin, $config, $eventManager); } @@ -202,6 +215,15 @@ private function validateAndLoadPlugin(string $plugin, array $config, $eventMana private function loadPlugin(string $plugin, array $config, $eventManager): void { + if (!PluginProcessor::hasValidEvent($config['plugin']['identifier'])) { + $this->logger->error( + 'Skipping plugin "' . $plugin . '": entry class could not be resolved. ' + . 'Ensure plugin.name, the PHP class name, and namespace App\\Addons\\' . $plugin . ' all match.' + ); + + return; + } + $this->plugins[] = $plugin; PluginProcessor::process($config['plugin']['identifier'], $eventManager); } diff --git a/backend/app/Services/Proxmox/Proxmox.php b/backend/app/Services/Proxmox/Proxmox.php index 27e2d7efc..69119b851 100644 --- a/backend/app/Services/Proxmox/Proxmox.php +++ b/backend/app/Services/Proxmox/Proxmox.php @@ -893,6 +893,23 @@ public function getTicketWithPassword(string $username, string $password): array } } + /** + * List Proxmox users from /api2/json/access/users. Requires User.Audit or User.Modify. + * + * @return array{ok: bool, users: array>, error: string|null} + */ + public function listUsers(): array + { + $result = $this->apiGet('/api2/json/access/users'); + if (!$result['ok']) { + return ['ok' => false, 'users' => [], 'error' => $result['error'] ?? 'unknown']; + } + + $users = is_array($result['data']) ? $result['data'] : []; + + return ['ok' => true, 'users' => $users, 'error' => null]; + } + /** * Create a Proxmox user (for temporary console access). Requires User.Modify. * diff --git a/backend/app/Services/Servers/ServerTransferInitiator.php b/backend/app/Services/Servers/ServerTransferInitiator.php index f2ab953b6..74416b4a3 100644 --- a/backend/app/Services/Servers/ServerTransferInitiator.php +++ b/backend/app/Services/Servers/ServerTransferInitiator.php @@ -25,6 +25,7 @@ use App\Chat\ServerTransfer; use App\Services\Wings\Wings; use App\Config\ConfigInterface; +use App\Helpers\WingsUrlHelper; use App\CloudFlare\CloudFlareRealIP; use App\Plugins\Events\Events\ServerEvent; @@ -39,6 +40,7 @@ class ServerTransferInitiator * - destination_allocation_id (optional) * - destination_additional_allocations (optional int[]) * - auto_allocate (bool, default true when primary allocation omitted) + * - auto_open_ports (bool, create missing allocations from Wings IPs) * * @return array{success: bool, error?: string, code?: string, http_status?: int, transfer_id?: int|false, new_allocation?: int|null, new_additional_allocations?: int[]} */ @@ -115,21 +117,26 @@ public function initiate(int $serverId, array $options, array $actingUser): arra } $autoAllocate = !array_key_exists('auto_allocate', $options) || $options['auto_allocate'] !== false; + $autoOpenPorts = !empty($options['auto_open_ports']); $excludeIds = array_merge( $newAllocationId ? [$newAllocationId] : [], $newAdditionalAllocations ); if ($newAllocationId === null && $autoAllocate) { - $picked = Allocation::pickFreeAllocationIdsForNode($destinationNodeId, $allocationCountNeeded, $excludeIds); - if (count($picked) < $allocationCountNeeded) { - return [ - 'success' => false, - 'error' => 'Not enough free allocations on the destination node (need ' . $allocationCountNeeded . ', found ' . count($picked) . ')', - 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', - 'http_status' => 400, - ]; + $pickResult = $this->pickTransferAllocations( + $autoOpenPorts, + $destinationNodeId, + $destinationNode, + $currentAllocations, + (int) $originalAllocationId, + $allocationCountNeeded, + $excludeIds + ); + if (!$pickResult['success']) { + return $pickResult; } + $picked = $pickResult['picked']; $newAllocationId = array_shift($picked); if (!empty($picked)) { $newAdditionalAllocations = array_merge($newAdditionalAllocations, $picked); @@ -143,25 +150,25 @@ public function initiate(int $serverId, array $options, array $actingUser): arra ]; } elseif ($autoAllocate && count($newAdditionalAllocations) < count($oldAdditionalAllocations)) { $stillNeeded = count($oldAdditionalAllocations) - count($newAdditionalAllocations); - $picked = Allocation::pickFreeAllocationIdsForNode( + $pickResult = $this->pickTransferAllocations( + $autoOpenPorts, $destinationNodeId, + $destinationNode, + $currentAllocations, + (int) $originalAllocationId, $stillNeeded, - array_merge($excludeIds, [$newAllocationId], $newAdditionalAllocations) + array_merge($excludeIds, [$newAllocationId], $newAdditionalAllocations), + true ); - if (count($picked) < $stillNeeded) { - return [ - 'success' => false, - 'error' => 'Not enough free allocations on the destination node for additional ports', - 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', - 'http_status' => 400, - ]; + if (!$pickResult['success']) { + return $pickResult; } - $newAdditionalAllocations = array_merge($newAdditionalAllocations, $picked); + $newAdditionalAllocations = array_merge($newAdditionalAllocations, $pickResult['picked']); } $config = App::getInstance(true)->getConfig(); $panelUrl = $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'); - $destinationUrl = $destinationNode['scheme'] . '://' . $destinationNode['fqdn'] . ':' . $destinationNode['daemonListen']; + $destinationUrl = WingsUrlHelper::buildFromNode($destinationNode); try { if ($newAllocationId) { @@ -181,13 +188,7 @@ public function initiate(int $serverId, array $options, array $actingUser): arra return ['success' => false, 'error' => 'Failed to update server status', 'code' => 'UPDATE_FAILED', 'http_status' => 500]; } - $wings = new Wings( - $sourceNode['fqdn'], - $sourceNode['daemonListen'], - $sourceNode['scheme'], - $sourceNode['daemon_token'], - 30 - ); + $wings = Wings::fromNode($sourceNode, 30); $jwtService = new \App\Services\Wings\Services\JwtService( $destinationNode['daemon_token'], @@ -412,4 +413,94 @@ public function massTransfer( 'skipped' => $skipped, ]; } + + /** + * @param array> $sourceAllocations + * @param array $excludeIds + * + * @return array{success: bool, picked: array, error?: string, code?: string, http_status?: int} + */ + private function pickTransferAllocations( + bool $autoOpenPorts, + int $destinationNodeId, + array $destinationNode, + array $sourceAllocations, + int $primaryAllocationId, + int $countNeeded, + array $excludeIds, + bool $additionalOnly = false, + ): array { + if ($countNeeded <= 0) { + return ['success' => true, 'picked' => []]; + } + + $provisioner = new TransferAllocationProvisioner(); + + if ($autoOpenPorts) { + $wingsIpsResult = $provisioner->fetchWingsIpAddresses($destinationNode); + if (!$wingsIpsResult['success']) { + return [ + 'success' => false, + 'picked' => [], + 'error' => $wingsIpsResult['error'] ?? 'Failed to read Wings IP list', + 'code' => $wingsIpsResult['code'] ?? 'WINGS_IPS_UNAVAILABLE', + 'http_status' => $wingsIpsResult['http_status'] ?? 503, + ]; + } + + $allSlots = $provisioner->resolveDestinationSlots( + $sourceAllocations, + $primaryAllocationId, + max(1, count($sourceAllocations)), + $wingsIpsResult['ips'], + $destinationNode + ); + $slots = $additionalOnly ? array_slice($allSlots, 1, $countNeeded) : array_slice($allSlots, 0, $countNeeded); + + $picked = Allocation::pickFreeAllocationIdsForSlots($destinationNodeId, $slots, $excludeIds); + if (count($picked) < $countNeeded) { + $missingSlots = array_slice($slots, count($picked)); + $provisionResult = $provisioner->provisionResolvedSlots($destinationNodeId, $missingSlots); + if (!$provisionResult['success']) { + return [ + 'success' => false, + 'picked' => [], + 'error' => $provisionResult['error'] ?? 'Failed to auto-open ports on destination node', + 'code' => $provisionResult['code'] ?? 'AUTO_OPEN_PORTS_FAILED', + 'http_status' => $provisionResult['http_status'] ?? 500, + ]; + } + $picked = Allocation::pickFreeAllocationIdsForSlots($destinationNodeId, $slots, $excludeIds); + } + + if (count($picked) < $countNeeded) { + return [ + 'success' => false, + 'picked' => [], + 'error' => $additionalOnly + ? 'Not enough free allocations on the destination node for additional ports' + : 'Not enough free allocations on the destination node (need ' . $countNeeded . ', found ' . count($picked) . ')', + 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', + 'http_status' => 400, + ]; + } + + return ['success' => true, 'picked' => $picked]; + } + + $picked = Allocation::pickFreeAllocationIdsForNode($destinationNodeId, $countNeeded, $excludeIds); + if (count($picked) < $countNeeded) { + return [ + 'success' => false, + 'picked' => [], + 'error' => $additionalOnly + ? 'Not enough free allocations on the destination node for additional ports' + : 'Not enough free allocations on the destination node (need ' . $countNeeded . ', found ' . count($picked) . ')', + 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', + 'http_status' => 400, + ]; + } + + return ['success' => true, 'picked' => $picked]; + } } diff --git a/backend/app/Services/Servers/TransferAllocationProvisioner.php b/backend/app/Services/Servers/TransferAllocationProvisioner.php new file mode 100644 index 000000000..4a36637b2 --- /dev/null +++ b/backend/app/Services/Servers/TransferAllocationProvisioner.php @@ -0,0 +1,305 @@ +. + */ + +namespace App\Services\Servers; + +use App\App; +use App\Chat\Allocation; +use App\Services\Wings\Wings; + +/** + * Creates missing panel allocations on a destination node during server transfer, + * using IPs reported by Wings and the source server's current ports. + */ +class TransferAllocationProvisioner +{ + /** + * @param array $destinationNode + * @param array> $sourceAllocations + * + * @return array{success: bool, created: int, error?: string, code?: string, http_status?: int} + */ + public function provisionMissing( + int $destinationNodeId, + array $destinationNode, + array $sourceAllocations, + int $primaryAllocationId, + int $countNeeded, + ): array { + if ($countNeeded <= 0) { + return ['success' => true, 'created' => 0]; + } + + $wingsIpsResult = $this->fetchWingsIpAddresses($destinationNode); + if (!$wingsIpsResult['success']) { + return array_merge($wingsIpsResult, ['created' => 0]); + } + + $resolvedSlots = $this->resolveDestinationSlots( + $sourceAllocations, + $primaryAllocationId, + $countNeeded, + $wingsIpsResult['ips'], + $destinationNode + ); + + if (empty($resolvedSlots)) { + return [ + 'success' => false, + 'created' => 0, + 'error' => 'Server has no allocations to mirror on the destination node', + 'code' => 'NO_SOURCE_ALLOCATIONS', + 'http_status' => 400, + ]; + } + + $created = 0; + foreach ($resolvedSlots as $slot) { + $targetIp = $slot['ip']; + $port = $slot['port']; + $existing = Allocation::getByNodeIpPort($destinationNodeId, $targetIp, $port); + + if ($existing !== null) { + if ($existing['server_id'] !== null) { + return [ + 'success' => false, + 'created' => $created, + 'error' => 'Port ' . $port . ' on ' . $targetIp . ' is already assigned on the destination node', + 'code' => 'DESTINATION_PORT_IN_USE', + 'http_status' => 400, + ]; + } + + continue; + } + + $allocationId = Allocation::create([ + 'node_id' => $destinationNodeId, + 'ip' => $targetIp, + 'port' => $port, + 'notes' => 'Auto-opened for server transfer', + ]); + + if (!$allocationId) { + return [ + 'success' => false, + 'created' => $created, + 'error' => 'Failed to create allocation for ' . $targetIp . ':' . $port . ' on destination node', + 'code' => 'ALLOCATION_CREATE_FAILED', + 'http_status' => 500, + ]; + } + + ++$created; + } + + return ['success' => true, 'created' => $created]; + } + + /** + * @param array $resolvedSlots + * + * @return array{success: bool, created: int, error?: string, code?: string, http_status?: int} + */ + public function provisionResolvedSlots(int $destinationNodeId, array $resolvedSlots): array + { + $created = 0; + + foreach ($resolvedSlots as $slot) { + $targetIp = $slot['ip']; + $port = $slot['port']; + $existing = Allocation::getByNodeIpPort($destinationNodeId, $targetIp, $port); + + if ($existing !== null) { + if ($existing['server_id'] !== null) { + return [ + 'success' => false, + 'created' => $created, + 'error' => 'Port ' . $port . ' on ' . $targetIp . ' is already assigned on the destination node', + 'code' => 'DESTINATION_PORT_IN_USE', + 'http_status' => 400, + ]; + } + + continue; + } + + $allocationId = Allocation::create([ + 'node_id' => $destinationNodeId, + 'ip' => $targetIp, + 'port' => $port, + 'notes' => 'Auto-opened for server transfer', + ]); + + if (!$allocationId) { + return [ + 'success' => false, + 'created' => $created, + 'error' => 'Failed to create allocation for ' . $targetIp . ':' . $port . ' on destination node', + 'code' => 'ALLOCATION_CREATE_FAILED', + 'http_status' => 500, + ]; + } + + ++$created; + } + + return ['success' => true, 'created' => $created]; + } + + /** + * @param array> $sourceAllocations + * @param array $wingsIps + * @param array $destinationNode + * + * @return array + */ + public function resolveDestinationSlots( + array $sourceAllocations, + int $primaryAllocationId, + int $countNeeded, + array $wingsIps, + array $destinationNode, + ): array { + $slots = array_slice($this->buildSourceSlots($sourceAllocations, $primaryAllocationId), 0, $countNeeded); + $resolved = []; + + foreach ($slots as $slot) { + $port = (int) ($slot['port'] ?? 0); + if ($port < 1 || $port > 65535) { + continue; + } + + $resolved[] = [ + 'ip' => $this->resolveTargetIp((string) ($slot['ip'] ?? ''), $wingsIps, $destinationNode), + 'port' => $port, + ]; + } + + return $resolved; + } + + /** + * @param array $destinationNode + * + * @return array{success: bool, ips?: array, error?: string, code?: string, http_status?: int} + */ + public function fetchWingsIpAddresses(array $destinationNode): array + { + try { + $wings = Wings::fromNode($destinationNode, 30); + $ipsResponse = $wings->getSystem()->getSystemIPs(); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error('Failed to fetch Wings IPs for transfer provisioning: ' . $e->getMessage()); + + return [ + 'success' => false, + 'error' => 'Could not reach destination node to read Wings IP list: ' . $e->getMessage(), + 'code' => 'WINGS_IPS_UNAVAILABLE', + 'http_status' => 503, + ]; + } + + $wingsIps = $this->extractWingsIpAddresses($ipsResponse); + if (empty($wingsIps)) { + return [ + 'success' => false, + 'error' => 'Destination node reported no usable IPs from Wings', + 'code' => 'WINGS_IPS_EMPTY', + 'http_status' => 400, + ]; + } + + return ['success' => true, 'ips' => $wingsIps]; + } + + /** + * @param array> $sourceAllocations + * + * @return array> + */ + private function buildSourceSlots(array $sourceAllocations, int $primaryAllocationId): array + { + $primary = null; + $additional = []; + + foreach ($sourceAllocations as $allocation) { + if ((int) $allocation['id'] === $primaryAllocationId) { + $primary = $allocation; + } else { + $additional[] = $allocation; + } + } + + if ($primary === null && !empty($sourceAllocations)) { + $primary = $sourceAllocations[0]; + $additional = array_slice($sourceAllocations, 1); + } + + $slots = []; + if ($primary !== null) { + $slots[] = $primary; + } + + return array_merge($slots, $additional); + } + + /** + * @param array $ipsResponse + * + * @return array + */ + private function extractWingsIpAddresses(array $ipsResponse): array + { + $raw = $ipsResponse['ip_addresses'] ?? $ipsResponse['data']['ip_addresses'] ?? []; + if (!is_array($raw)) { + return []; + } + + $ips = []; + foreach ($raw as $ip) { + if (!is_string($ip) || trim($ip) === '') { + continue; + } + $ips[] = trim($ip); + } + + return array_values(array_unique($ips)); + } + + /** + * @param array $wingsIps + * @param array $destinationNode + */ + private function resolveTargetIp(string $sourceIp, array $wingsIps, array $destinationNode): string + { + if ($sourceIp !== '' && in_array($sourceIp, $wingsIps, true)) { + return $sourceIp; + } + + $publicIp = trim((string) ($destinationNode['public_ip_v4'] ?? '')); + if ($publicIp !== '' && in_array($publicIp, $wingsIps, true)) { + return $publicIp; + } + + if (in_array('0.0.0.0', $wingsIps, true)) { + return '0.0.0.0'; + } + + return $wingsIps[0]; + } +} diff --git a/backend/app/Services/Tickets/TicketAdminNotifier.php b/backend/app/Services/Tickets/TicketAdminNotifier.php new file mode 100644 index 000000000..2ce704189 --- /dev/null +++ b/backend/app/Services/Tickets/TicketAdminNotifier.php @@ -0,0 +1,100 @@ +. + */ + +namespace App\Services\Tickets; + +use App\App; +use App\Chat\User; +use App\Permissions; +use App\Chat\Permission; +use App\Config\ConfigInterface; +use App\Mail\templates\TicketAdminAlert; + +class TicketAdminNotifier +{ + /** + * Notify staff with ticket view permission about a new ticket or user reply. + * + * @param array $ticket Enriched or raw ticket row + * @param string $event Either `new_ticket` or `user_reply` + * @param string $actorUuid UUID of the user who triggered the event (excluded from recipients) + */ + public static function notify(array $ticket, string $event, string $actorUuid = ''): void + { + if (!in_array($event, ['new_ticket', 'user_reply'], true)) { + return; + } + + $app = App::getInstance(true); + $config = $app->getConfig(); + $smtpEnabled = $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'); + if ($smtpEnabled !== 'true') { + return; + } + + $appName = $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'); + $appUrl = rtrim($config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), '/'); + $supportUrl = $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'); + + $roleIds = Permission::getRoleIdsWithPermission(Permissions::ADMIN_TICKETS_VIEW); + if ($roleIds === []) { + return; + } + + $admins = User::getActiveUsersByRoleIds($roleIds); + if ($admins === []) { + return; + } + + $ticketTitle = (string) ($ticket['title'] ?? 'Support ticket'); + $ticketUuid = (string) ($ticket['uuid'] ?? ''); + $ticketUrl = $appUrl . '/admin/tickets/' . $ticketUuid; + + $ticketOwner = null; + if (!empty($ticket['user_uuid'])) { + $ticketOwner = User::getUserByUuid((string) $ticket['user_uuid']); + } + + $ownerLabel = $ticketOwner + ? trim(($ticketOwner['username'] ?? '') . ' (' . ($ticketOwner['email'] ?? '') . ')') + : 'Unknown user'; + + foreach ($admins as $admin) { + if ($actorUuid !== '' && ($admin['uuid'] ?? '') === $actorUuid) { + continue; + } + + TicketAdminAlert::send([ + 'uuid' => $admin['uuid'], + 'email' => $admin['email'], + 'first_name' => $admin['first_name'], + 'last_name' => $admin['last_name'], + 'username' => $admin['username'], + 'app_name' => $appName, + 'app_url' => $appUrl, + 'app_support_url' => $supportUrl, + 'enabled' => $smtpEnabled, + 'event' => $event, + 'ticket_title' => $ticketTitle, + 'ticket_uuid' => $ticketUuid, + 'ticket_url' => $ticketUrl, + 'ticket_owner' => $ownerLabel, + 'open_tickets_count' => (string) \App\Chat\Ticket::getGlobalOpenTicketsCount(), + ]); + } + } +} diff --git a/backend/app/Services/Tickets/TicketNotificationService.php b/backend/app/Services/Tickets/TicketNotificationService.php new file mode 100644 index 000000000..da4f92c32 --- /dev/null +++ b/backend/app/Services/Tickets/TicketNotificationService.php @@ -0,0 +1,175 @@ +. + */ + +namespace App\Services\Tickets; + +use App\App; +use App\Chat\User; +use App\Config\ConfigInterface; +use App\Mail\templates\TicketClosed; +use App\Mail\templates\TicketReplied; +use App\Mail\templates\TicketReopened; + +class TicketNotificationService +{ + public static function notifyReply(array $ticket, array $message, ?string $replierUuid = null): void + { + if ((int) ($message['is_internal'] ?? 0) === 1) { + return; + } + + $ticketOwnerUuid = (string) ($ticket['user_uuid'] ?? ''); + if ($ticketOwnerUuid === '') { + return; + } + + if ($replierUuid !== null && $replierUuid === $ticketOwnerUuid) { + return; + } + + $owner = User::getUserByUuid($ticketOwnerUuid); + if ($owner === null) { + return; + } + + $replier = $replierUuid !== null ? User::getUserByUuid($replierUuid) : null; + $data = self::buildBaseMailData($owner, $ticket); + $data['reply_preview'] = self::buildMessagePreview((string) ($message['message'] ?? '')); + $data['replier_name'] = self::formatReplierName($replier); + $data['subject'] = 'New reply on your ticket: ' . ($ticket['title'] ?? 'Support Ticket'); + + try { + TicketReplied::send($data); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error('Failed to queue ticket reply email: ' . $e->getMessage()); + } + } + + public static function notifyClosed(array $ticket): void + { + $owner = self::getTicketOwner($ticket); + if ($owner === null) { + return; + } + + $data = self::buildBaseMailData($owner, $ticket); + $data['subject'] = 'Your ticket has been closed: ' . ($ticket['title'] ?? 'Support Ticket'); + + try { + TicketClosed::send($data); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error('Failed to queue ticket closed email: ' . $e->getMessage()); + } + } + + public static function notifyReopened(array $ticket): void + { + $owner = self::getTicketOwner($ticket); + if ($owner === null) { + return; + } + + $data = self::buildBaseMailData($owner, $ticket); + $data['subject'] = 'Your ticket has been reopened: ' . ($ticket['title'] ?? 'Support Ticket'); + + try { + TicketReopened::send($data); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error('Failed to queue ticket reopened email: ' . $e->getMessage()); + } + } + + private static function getTicketOwner(array $ticket): ?array + { + $ticketOwnerUuid = (string) ($ticket['user_uuid'] ?? ''); + if ($ticketOwnerUuid === '') { + return null; + } + + return User::getUserByUuid($ticketOwnerUuid); + } + + /** + * @return array + */ + private static function buildBaseMailData(array $owner, array $ticket): array + { + $app = App::getInstance(false, true); + $config = $app->getConfig(); + $appUrl = (string) $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'); + if (!preg_match('#^https?://#i', $appUrl)) { + $appUrl = 'https://' . ltrim($appUrl, '/'); + } + $appUrl = rtrim($appUrl, '/'); + + return [ + 'email' => (string) $owner['email'], + 'uuid' => (string) $owner['uuid'], + 'first_name' => (string) $owner['first_name'], + 'last_name' => (string) $owner['last_name'], + 'username' => (string) $owner['username'], + 'app_name' => (string) $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_url' => $appUrl, + 'app_support_url' => (string) $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + 'enabled' => (string) $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), + 'ticket_title' => (string) ($ticket['title'] ?? 'Support Ticket'), + 'ticket_url' => $appUrl . '/dashboard/tickets/' . ($ticket['uuid'] ?? ''), + 'dashboard_url' => $appUrl . '/dashboard', + 'support_url' => (string) $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + ]; + } + + private static function formatReplierName(?array $replier): string + { + if ($replier === null) { + return 'Support Team'; + } + + $firstName = trim((string) ($replier['first_name'] ?? '')); + $lastName = trim((string) ($replier['last_name'] ?? '')); + $fullName = trim($firstName . ' ' . $lastName); + if ($fullName !== '') { + return $fullName; + } + + $username = trim((string) ($replier['username'] ?? '')); + if ($username !== '') { + return $username; + } + + return 'Support Team'; + } + + private static function buildMessagePreview(string $message): string + { + $message = trim($message); + if ($message === '') { + return ''; + } + + $signaturePos = strpos($message, "\n\n---\n"); + if ($signaturePos !== false) { + $message = trim(substr($message, 0, $signaturePos)); + } + + if (strlen($message) > 500) { + $message = substr($message, 0, 497) . '...'; + } + + return nl2br(htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + } +} diff --git a/backend/app/Services/UserDataExport/UserDataExportService.php b/backend/app/Services/UserDataExport/UserDataExportService.php index 9d5c2b619..47cc44baf 100644 --- a/backend/app/Services/UserDataExport/UserDataExportService.php +++ b/backend/app/Services/UserDataExport/UserDataExportService.php @@ -24,6 +24,7 @@ use GuzzleHttp\Client; use App\Services\Wings\Wings; use App\Config\ConfigInterface; +use App\Helpers\WingsUrlHelper; use App\Services\Wings\Services\JwtService; /** @@ -835,7 +836,7 @@ private function downloadBackupArchive(array $server, array $backup, string $arc throw new \RuntimeException('Server owner not found for backup download token'); } - $baseWingsUrl = rtrim((string) $node['scheme'] . '://' . $node['fqdn'] . ':' . $node['daemonListen'], '/'); + $baseWingsUrl = rtrim(WingsUrlHelper::buildFromNode($node), '/'); $jwtService = new JwtService( (string) $node['daemon_token'], App::getInstance(true)->getConfig()->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.local'), diff --git a/backend/app/Services/Vm/VmInstanceUtil.php b/backend/app/Services/Vm/VmInstanceUtil.php index 072da1f4e..47e846671 100644 --- a/backend/app/Services/Vm/VmInstanceUtil.php +++ b/backend/app/Services/Vm/VmInstanceUtil.php @@ -42,6 +42,9 @@ final class VmInstanceUtil { + /** Prefix for short-lived Proxmox users created for VNC console access. */ + public const FP_CONSOLE_USER_PREFIX = 'fp-console-'; + /** * Build a Proxmox client for the given VM node (shared by admin and user controllers). * @@ -801,7 +804,7 @@ public static function createVncTicketPayload(array $instance, array $vmNode, in 'wss_url' => $wssUrl, ]; - $tempUser = 'fp-console-' . $instanceIdForLabel . '-' . bin2hex(random_bytes(4)) . '@pve'; + $tempUser = self::FP_CONSOLE_USER_PREFIX . $instanceIdForLabel . '-' . bin2hex(random_bytes(4)) . '@pve'; $tempPass = bin2hex(random_bytes(16)); $expire = time() + 300; $cr = $client->createUser($tempUser, $tempPass, $expire); @@ -823,6 +826,71 @@ public static function createVncTicketPayload(array $instance, array $vmNode, in return ['ok' => true, 'payload' => $payload]; } + /** + * Delete expired temporary console users (fp-console-*) on a Proxmox node. + * + * @param array $vmNode VM node row from VmNode::getAllVmNodes() + * + * @return array{deleted: int, errors: int} + */ + public static function cleanupExpiredFpConsoleUsersOnNode(array $vmNode): array + { + $deleted = 0; + $errors = 0; + $now = time(); + + try { + $client = self::buildProxmoxClientForNode($vmNode); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error( + 'Proxmox client build failed (console user cleanup) for node ' + . ($vmNode['id'] ?? 'unknown') . ': ' . $e->getMessage() + ); + + return ['deleted' => 0, 'errors' => 1]; + } + + $list = $client->listUsers(); + if (!$list['ok']) { + App::getInstance(true)->getLogger()->error( + 'Proxmox listUsers failed (console user cleanup) for node ' + . ($vmNode['id'] ?? 'unknown') . ': ' . ($list['error'] ?? 'unknown') + ); + + return ['deleted' => 0, 'errors' => 1]; + } + + foreach ($list['users'] as $user) { + if (!is_array($user)) { + continue; + } + + $userid = isset($user['userid']) ? (string) $user['userid'] : ''; + if ($userid === '' || !str_starts_with($userid, self::FP_CONSOLE_USER_PREFIX)) { + continue; + } + + $expire = isset($user['expire']) ? (int) $user['expire'] : 0; + if ($expire <= 0 || $expire > $now) { + continue; + } + + $result = $client->deleteUser($userid); + if ($result['ok']) { + ++$deleted; + continue; + } + + ++$errors; + App::getInstance(true)->getLogger()->warning( + 'Failed to delete expired Proxmox console user ' . $userid . ' on node ' + . ($vmNode['id'] ?? 'unknown') . ': ' . ($result['error'] ?? 'unknown') + ); + } + + return ['deleted' => $deleted, 'errors' => $errors]; + } + /** * Create a background task record. */ diff --git a/backend/app/Services/Wings/Wings.php b/backend/app/Services/Wings/Wings.php index 30f8ac90b..5c6389879 100755 --- a/backend/app/Services/Wings/Wings.php +++ b/backend/app/Services/Wings/Wings.php @@ -17,6 +17,7 @@ namespace App\Services\Wings; +use App\Helpers\WingsUrlHelper; use App\Services\Wings\Services\JwtService; use App\Services\Wings\Services\ConfigService; use App\Services\Wings\Services\DockerService; @@ -50,6 +51,7 @@ class Wings * @param string $protocol The protocol to use (http/https) * @param string $authToken The authentication token for Wings * @param int $timeout Request timeout in seconds (default: 30) + * @param bool $behindProxy Whether Wings is accessed through a reverse proxy */ public function __construct( string $host, @@ -57,8 +59,9 @@ public function __construct( string $protocol = 'http', string $authToken = '', int $timeout = 30, + bool $behindProxy = false, ) { - $this->connection = new WingsConnection($host, $port, $protocol, $authToken, $timeout); + $this->connection = new WingsConnection($host, $port, $protocol, $authToken, $timeout, $behindProxy); // Initialize service classes $this->system = new SystemService($this->connection); @@ -72,6 +75,21 @@ public function __construct( $this->jwt = new JwtService($authToken, '', $this->connection->getBaseUrl()); } + /** + * Create a Wings client from a node database row. + */ + public static function fromNode(array $node, int $timeout = 30): self + { + return new self( + (string) ($node['fqdn'] ?? 'localhost'), + (int) ($node['daemonListen'] ?? 8443), + (string) ($node['scheme'] ?? 'http'), + (string) ($node['daemon_token'] ?? ''), + $timeout, + WingsUrlHelper::isBehindProxy($node), + ); + } + /** * Get the system service. */ diff --git a/backend/app/Services/Wings/WingsConnection.php b/backend/app/Services/Wings/WingsConnection.php index 478f46074..cf8633ee0 100755 --- a/backend/app/Services/Wings/WingsConnection.php +++ b/backend/app/Services/Wings/WingsConnection.php @@ -20,6 +20,7 @@ use App\App; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Request; +use App\Helpers\WingsUrlHelper; use App\Services\Wings\Utils\DnsResolver; use App\Services\Wings\Utils\TokenGenerator; use App\Services\Wings\Exceptions\WingsRequestException; @@ -51,6 +52,7 @@ class WingsConnection * @param string $protocol The protocol to use (http/https) * @param string $authToken The authentication token for Wings * @param int $timeout Request timeout in seconds (default: 30) + * @param bool $behindProxy Whether Wings is accessed through a reverse proxy */ public function __construct( string $host, @@ -58,6 +60,7 @@ public function __construct( string $protocol = 'http', string $authToken = '', int $timeout = 30, + bool $behindProxy = false, ) { $this->protocol = $protocol; $this->port = $port; @@ -65,7 +68,7 @@ public function __construct( $this->timeout = $timeout; // Build base URL - $this->baseUrl = $this->buildBaseUrl($host, $port, $protocol); + $this->baseUrl = $this->buildBaseUrl($host, $port, $protocol, $behindProxy); // Initialize token generator $this->tokenGenerator = new TokenGenerator(); @@ -599,11 +602,9 @@ public function getSystemIPs(): array /** * Build the base URL for the Wings API. */ - private function buildBaseUrl(string $host, int $port, string $protocol): string + private function buildBaseUrl(string $host, int $port, string $protocol, bool $behindProxy = false): string { - $host = rtrim($host, '/'); - - return "{$protocol}://{$host}:{$port}"; + return WingsUrlHelper::buildBaseUrl($protocol, $host, $port, $behindProxy); } /** diff --git a/backend/app/routes/admin/users.php b/backend/app/routes/admin/users.php index 5929b2a54..84c36cf2a 100755 --- a/backend/app/routes/admin/users.php +++ b/backend/app/routes/admin/users.php @@ -130,6 +130,46 @@ function (Request $request, array $args) { Permissions::ADMIN_USERS_VIEW, ['GET'] ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-users-potential-alts', + '/api/admin/users/{uuid}/potential-alts', + function (Request $request, array $args) { + $uuid = $args['uuid'] ?? null; + if (!$uuid || !is_string($uuid)) { + return ApiResponse::error('Missing or invalid UUID', 'INVALID_UUID', 400); + } + + return (new UsersController())->potentialAlts($request, $uuid); + }, + Permissions::ADMIN_USERS_VIEW, + ['GET'] + ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-users-clear-devices', + '/api/admin/users/{uuid}/devices', + function (Request $request, array $args) { + $uuid = $args['uuid'] ?? null; + if (!$uuid || !is_string($uuid)) { + return ApiResponse::error('Missing or invalid UUID', 'INVALID_UUID', 400); + } + + return (new UsersController())->clearUserDevices($request, $uuid); + }, + Permissions::ADMIN_USERS_EDIT, + ['DELETE'] + ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-devices-clear-all', + '/api/admin/devices', + function (Request $request) { + return (new UsersController())->clearAllDevices($request); + }, + Permissions::ADMIN_USERS_EDIT, + ['DELETE'] + ); App::getInstance(true)->registerAdminRoute( $routes, 'admin-users-server-request', @@ -175,6 +215,26 @@ function (Request $request, array $args) { Permissions::ADMIN_USERS_EDIT, ['POST'] ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-users-resend-mail', + '/api/admin/users/{uuid}/mails/{id}/resend', + function (Request $request, array $args) { + $uuid = $args['uuid'] ?? null; + if (!$uuid || !is_string($uuid)) { + return ApiResponse::error('Missing or invalid UUID', 'INVALID_UUID', 400); + } + + $id = $args['id'] ?? null; + if (!$id || !is_numeric($id)) { + return ApiResponse::error('Missing or invalid mail ID', 'INVALID_MAIL_ID', 400); + } + + return (new UsersController())->resendMail($request, $uuid, (int) $id); + }, + Permissions::ADMIN_USERS_EDIT, + ['POST'] + ); App::getInstance(true)->registerAdminRoute( $routes, 'admin-users-verify-email', diff --git a/backend/app/routes/user/auth.php b/backend/app/routes/user/auth.php index c199bfce3..05d70b63b 100755 --- a/backend/app/routes/user/auth.php +++ b/backend/app/routes/user/auth.php @@ -193,6 +193,18 @@ function (Request $request) { 'user-auth-discord' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'discord-link-initiate', + '/api/user/auth/discord/link', + function (Request $request) { + return (new DiscordController())->linkAccount($request); + }, + ['GET'], + Rate::perMinute(5), + 'user-auth-discord' + ); + App::getInstance(true)->registerApiRoute( $routes, 'discord-link', diff --git a/backend/app/routes/user/session.php b/backend/app/routes/user/session.php index 6846e0cb0..c6859b6ef 100755 --- a/backend/app/routes/user/session.php +++ b/backend/app/routes/user/session.php @@ -97,6 +97,22 @@ function (Request $request) { Rate::perMinute(30), // Default: Admin can override in ratelimit.json 'user-session' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'mails-resend', + '/api/user/mails/{id}/resend', + function (Request $request, array $args) { + $id = $args['id'] ?? null; + if (!$id || !is_numeric($id)) { + return \App\Helpers\ApiResponse::error('Missing or invalid mail ID', 'INVALID_MAIL_ID', 400); + } + + return (new SessionController())->resendMail($request, (int) $id); + }, + ['POST'], + Rate::perMinute(10), + 'user-session' + ); App::getInstance(true)->registerAuthRoute( $routes, 'activities-get', diff --git a/backend/cli b/backend/cli index 994acd67b..f0d945314 100755 --- a/backend/cli +++ b/backend/cli @@ -42,7 +42,7 @@ define('APP_START', microtime(true)); define('APP_DIR', APP_PUBLIC . '/'); define('APP_CRON_DIR', APP_PUBLIC . '/storage/cron/'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.3'); +define('APP_VERSION', 'v1.3.7.4'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); define('TELEMETRY', true); diff --git a/backend/composer.lock b/backend/composer.lock index 01aa2392c..93809fd09 100755 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -75,16 +75,16 @@ }, { "name": "brick/math", - "version": "0.17.1", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -122,7 +122,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.1" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -130,7 +130,7 @@ "type": "github" } ], - "time": "2026-04-19T20:55:20+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "cloudflare/sdk", @@ -507,25 +507,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.2", + "version": "7.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "aed36fd5fb4844f284252a999d9abf35d3a9a1ae" + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aed36fd5fb4844f284252a999d9abf35d3a9a1ae", - "reference": "aed36fd5fb4844f284252a999d9abf35d3a9a1ae", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-client-implementation": "1.0" @@ -534,7 +535,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.3.2", + "guzzlehttp/test-server": "^0.5", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -614,7 +615,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.2" + "source": "https://github.com/guzzle/guzzle/tree/7.11.1" }, "funding": [ { @@ -630,24 +631,25 @@ "type": "tidelift" } ], - "time": "2026-05-20T11:58:52+00:00" + "time": "2026-06-07T22:54:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -697,7 +699,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.1" + "source": "https://github.com/guzzle/promises/tree/2.5.0" }, "funding": [ { @@ -713,27 +715,29 @@ "type": "tidelift" } ], - "time": "2026-05-19T18:30:48+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.1", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -814,7 +818,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.1" + "source": "https://github.com/guzzle/psr7/tree/2.11.0" }, "funding": [ { @@ -830,7 +834,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:36+00:00" + "time": "2026-06-02T12:30:48+00:00" }, { "name": "ifsnop/mysqldump-php", @@ -1618,16 +1622,16 @@ }, { "name": "predis/predis", - "version": "v3.4.2", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" + "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", + "url": "https://api.github.com/repos/predis/predis/zipball/8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", + "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", "shasum": "" }, "require": { @@ -1669,7 +1673,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.4.2" + "source": "https://github.com/predis/predis/tree/v3.5.0" }, "funding": [ { @@ -1677,7 +1681,7 @@ "type": "github" } ], - "time": "2026-03-09T20:33:04+00:00" + "time": "2026-06-02T19:25:56+00:00" }, { "name": "psr/clock", @@ -2267,20 +2271,20 @@ }, { "name": "symfony/clock", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -2320,7 +2324,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.8" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -2340,7 +2344,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2415,20 +2419,20 @@ }, { "name": "symfony/finder", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8da41214757b87d97f181e3d14a4179286151007" + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", - "reference": "8da41214757b87d97f181e3d14a4179286151007", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/filesystem": "^7.4|^8.0" @@ -2459,7 +2463,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.8" + "source": "https://github.com/symfony/finder/tree/v8.1.0" }, "funding": [ { @@ -2479,20 +2483,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "bc354f47c62301e990b7874fa662326368508e2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", "shasum": "" }, "require": { @@ -2541,7 +2545,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" }, "funding": [ { @@ -2561,20 +2565,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/mime", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -2630,7 +2634,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.12" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -2650,7 +2654,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2737,16 +2741,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -2795,7 +2799,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -2815,20 +2819,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -2882,7 +2886,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -2902,20 +2906,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -2967,7 +2971,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -2987,20 +2991,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -3052,7 +3056,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3072,7 +3076,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -3243,16 +3247,16 @@ }, { "name": "symfony/process", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -3284,7 +3288,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.11" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -3304,24 +3308,24 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/property-access", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4" + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/704c7808116fcdd67327db7b17de56b8ef6169e4", - "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/property-info": "^7.4.4|^8.0.4" }, "require-dev": { @@ -3365,7 +3369,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v8.0.8" + "source": "https://github.com/symfony/property-access/tree/v8.1.0" }, "funding": [ { @@ -3385,24 +3389,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/property-info", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6" + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/c21711980653360d6ef5c26d0f9ca6f58a1135c6", - "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/string": "^7.4|^8.0", "symfony/type-info": "^7.4.7|^8.0.7" }, @@ -3451,7 +3455,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.0.8" + "source": "https://github.com/symfony/property-info/tree/v8.1.0" }, "funding": [ { @@ -3471,20 +3475,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/routing", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { @@ -3536,7 +3540,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.12" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -3556,30 +3560,31 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/serializer", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf" + "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf", - "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "url": "https://api.github.com/repos/symfony/serializer/zipball/d101886195c5f772cf7033641fe9c40c3e3969e1", + "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/property-access": "<7.4.2|>=8.0,<8.0.2", + "symfony/property-access": "<8.1", "symfony/property-info": "<7.4", "symfony/type-info": "<7.4" }, @@ -3598,7 +3603,7 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", "symfony/mime": "^7.4|^8.0", - "symfony/property-access": "^7.4.2|^8.0.2", + "symfony/property-access": "^8.1", "symfony/property-info": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.4|^8.0", @@ -3634,7 +3639,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.0.10" + "source": "https://github.com/symfony/serializer/tree/v8.1.0" }, "funding": [ { @@ -3654,24 +3659,24 @@ "type": "tidelift" } ], - "time": "2026-05-04T13:41:39+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -3724,7 +3729,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.11" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -3744,24 +3749,24 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/type-info", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", - "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/container": "^1.1|^2.0" }, "conflict": { @@ -3806,7 +3811,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.9" + "source": "https://github.com/symfony/type-info/tree/v8.1.0" }, "funding": [ { @@ -3826,24 +3831,24 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/uid", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", - "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { @@ -3884,7 +3889,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.9" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -3904,20 +3909,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T16:10:06+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", - "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", "shasum": "" }, "require": { @@ -3960,7 +3965,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.12" + "source": "https://github.com/symfony/yaml/tree/v7.4.13" }, "funding": [ { @@ -3980,7 +3985,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-25T06:06:12+00:00" }, { "name": "vlucas/phpdotenv", @@ -4139,16 +4144,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.3.3", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -4209,7 +4214,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -4221,7 +4226,7 @@ "type": "patreon" } ], - "time": "2026-05-17T19:04:30+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", @@ -4850,23 +4855,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.2", + "version": "v3.95.4", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" + "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72", + "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", - "ergebnis/agent-detector": "^1.1.1", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -4883,10 +4888,10 @@ "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.33", - "symfony/polyfill-php80": "^1.33", - "symfony/polyfill-php81": "^1.33", - "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, @@ -4900,9 +4905,9 @@ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php85": "^1.37", "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -4943,7 +4948,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4" }, "funding": [ { @@ -4951,7 +4956,7 @@ "type": "github" } ], - "time": "2026-05-15T09:20:44+00:00" + "time": "2026-06-03T18:02:44+00:00" }, { "name": "myclabs/deep-copy", @@ -5133,11 +5138,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -5160,6 +5165,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -5182,7 +5198,7 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7286,23 +7302,29 @@ }, { "name": "symfony/console", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", - "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4|^8.0" + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" @@ -7310,14 +7332,18 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", @@ -7352,7 +7378,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.11" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { @@ -7372,24 +7398,25 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -7437,7 +7464,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -7457,7 +7484,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7541,20 +7568,21 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -7587,7 +7615,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" }, "funding": [ { @@ -7607,24 +7635,24 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:39:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -7658,7 +7686,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -7678,20 +7706,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -7738,7 +7766,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -7758,20 +7786,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -7818,7 +7846,87 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -7838,7 +7946,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/service-contracts", @@ -7929,20 +8037,20 @@ }, { "name": "symfony/stopwatch", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -7971,7 +8079,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -7991,7 +8099,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "theseer/tokenizer", diff --git a/backend/public/index.php b/backend/public/index.php index a7fdbe31b..6a90677ae 100755 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -34,7 +34,7 @@ define('SYSTEM_OS_NAME', gethostname() . '/' . PHP_OS_FAMILY); define('SYSTEM_KERNEL_NAME', php_uname('s')); define('TELEMETRY', true); -define('APP_VERSION', 'v1.3.7.3'); +define('APP_VERSION', 'v1.3.7.4'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); diff --git a/backend/storage/addons/.gitignore b/backend/storage/addons/.gitignore index afb2f44c7..5a1c7cb99 100755 --- a/backend/storage/addons/.gitignore +++ b/backend/storage/addons/.gitignore @@ -1,2 +1,3 @@ /*/ -*.sh \ No newline at end of file +*.sh +*.build.log \ No newline at end of file diff --git a/backend/storage/cron/php/AServerScheduleProcessor.php b/backend/storage/cron/php/AServerScheduleProcessor.php index 0ed400f7c..d1600ffc4 100755 --- a/backend/storage/cron/php/AServerScheduleProcessor.php +++ b/backend/storage/cron/php/AServerScheduleProcessor.php @@ -680,13 +680,7 @@ private function getWingsConnection(array $server): Wings throw new \Exception('Node not found for server: ' . $server['name']); } - $scheme = $node['scheme']; - $host = $node['fqdn']; - $port = $node['daemonListen']; - $token = $node['daemon_token']; - $timeout = 30; - - return new Wings($host, $port, $scheme, $token, $timeout); + return Wings::fromNode($node, 30); } /** diff --git a/backend/storage/cron/php/UserDataExportProcessor.php b/backend/storage/cron/php/UserDataExportProcessor.php index 1f301fe76..7dec258d8 100644 --- a/backend/storage/cron/php/UserDataExportProcessor.php +++ b/backend/storage/cron/php/UserDataExportProcessor.php @@ -29,6 +29,7 @@ use App\Chat\TicketAttachment; use App\Config\ConfigInterface; use App\Cli\Utils\MinecraftColorCodeSupport; +use App\Services\Tickets\TicketNotificationService; use App\Services\UserDataExport\UserDataExportService; /** @@ -269,6 +270,11 @@ private function processExport(UserDataExportService $service, array $export): v throw new \RuntimeException('Failed to create system reply for export ticket'); } + $createdMessage = TicketMessage::getById($messageId); + if ($createdMessage) { + TicketNotificationService::notifyReply($ticket, $createdMessage); + } + $attachmentId = TicketAttachment::create([ 'ticket_id' => (int) $ticket['id'], 'message_id' => $messageId, diff --git a/backend/storage/cron/php/ZProxmoxConsoleUserCleanup.php b/backend/storage/cron/php/ZProxmoxConsoleUserCleanup.php new file mode 100644 index 000000000..024d84522 --- /dev/null +++ b/backend/storage/cron/php/ZProxmoxConsoleUserCleanup.php @@ -0,0 +1,115 @@ +. + */ + +namespace App\Cron; + +/** + * ProxmoxConsoleUserCleanup - Removes expired fp-console-* Proxmox users. + * + * FeatherPanel creates short-lived Proxmox users for VNC console access. Proxmox + * does not remove users when their expire date passes, so this job deletes them. + * + * Schedule: every hour. + */ + +use App\App; +use App\Chat\VmNode; +use App\Chat\TimedTask; +use App\Services\Vm\VmInstanceUtil; +use App\Cli\Utils\MinecraftColorCodeSupport; + +class ZProxmoxConsoleUserCleanup implements TimeTask +{ + private const TASK_NAME = 'proxmox-console-user-cleanup'; + + /** + * Entry point for the cron ProxmoxConsoleUserCleanup. + */ + public function run() + { + $cron = new Cron(self::TASK_NAME, '1H'); + $force = true; + try { + $cron->runIfDue(function () { + $this->processTask(); + }, $force); + } catch (\Exception $e) { + $app = App::getInstance(false, true); + $app->getLogger()->error('Failed to process ProxmoxConsoleUserCleanup: ' . $e->getMessage()); + TimedTask::markRun(self::TASK_NAME, false, $e->getMessage()); + } + } + + /** + * Process the main task logic. + */ + private function processTask() + { + $app = App::getInstance(false, true); + $logger = $app->getLogger(); + MinecraftColorCodeSupport::sendOutputWithNewLine('&aProcessing Proxmox console user cleanup...'); + + $nodes = VmNode::getAllVmNodes(); + if ($nodes === []) { + MinecraftColorCodeSupport::sendOutputWithNewLine('&7No VM nodes configured, nothing to clean.'); + TimedTask::markRun(self::TASK_NAME, true, 'No VM nodes'); + + return; + } + + $totalDeleted = 0; + $totalErrors = 0; + + foreach ($nodes as $vmNode) { + $nodeLabel = ($vmNode['name'] ?? '') !== '' + ? (string) $vmNode['name'] + : ('#' . ($vmNode['id'] ?? '?')); + + try { + $result = VmInstanceUtil::cleanupExpiredFpConsoleUsersOnNode($vmNode); + $totalDeleted += $result['deleted']; + $totalErrors += $result['errors']; + + if ($result['deleted'] > 0) { + MinecraftColorCodeSupport::sendOutputWithNewLine( + '&aNode ' . $nodeLabel . ': removed ' . $result['deleted'] . ' expired console user(s)' + ); + } + } catch (\Throwable $e) { + ++$totalErrors; + $logger->error( + 'Proxmox console user cleanup failed for node ' . $nodeLabel . ': ' . $e->getMessage() + ); + MinecraftColorCodeSupport::sendOutputWithNewLine( + '&cNode ' . $nodeLabel . ': cleanup failed: ' . $e->getMessage() + ); + } + } + + if ($totalDeleted > 0) { + $logger->info('Proxmox console user cleanup removed ' . $totalDeleted . ' expired user(s)'); + } + + $summary = $totalDeleted . ' user(s) removed'; + if ($totalErrors > 0) { + $summary .= ', ' . $totalErrors . ' error(s)'; + } + + MinecraftColorCodeSupport::sendOutputWithNewLine('&aProxmox console user cleanup completed (' . $summary . ')'); + TimedTask::markRun(self::TASK_NAME, $totalErrors === 0, $summary); + } +} diff --git a/backend/storage/migrations/2026-06-07.12.00-add-spell-default-docker-image.sql b/backend/storage/migrations/2026-06-07.12.00-add-spell-default-docker-image.sql new file mode 100644 index 000000000..1ca05ba20 --- /dev/null +++ b/backend/storage/migrations/2026-06-07.12.00-add-spell-default-docker-image.sql @@ -0,0 +1,2 @@ +ALTER TABLE `featherpanel_spells` +ADD COLUMN `default_docker_image` VARCHAR(191) DEFAULT NULL AFTER `docker_images`; diff --git a/backend/storage/migrations/2026-06-07.12.00-add-ticket-admin-alert-mail-template.sql b/backend/storage/migrations/2026-06-07.12.00-add-ticket-admin-alert-mail-template.sql new file mode 100644 index 000000000..b1f7e29bd --- /dev/null +++ b/backend/storage/migrations/2026-06-07.12.00-add-ticket-admin-alert-mail-template.sql @@ -0,0 +1,19 @@ +DELETE FROM featherpanel_mail_templates +WHERE `featherpanel_mail_templates`.`name` = 'ticket_admin_alert'; + +INSERT INTO + `featherpanel_mail_templates` ( + `name`, + `subject`, + `body`, + `deleted`, + `locked` + ) +VALUES + ( + 'ticket_admin_alert', + '[{app_name}] {event_label}', + 'Support ticket alert

Support ticket needs attention

{event_label}

Hello, {first_name} {last_name}

Ticket {ticket_title} from {ticket_owner} requires staff attention. There are currently {open_tickets_count} open ticket(s) on {app_name}.

This email was sent to {email}

© 2024-2026 {app_name}. All rights reserved.

Support

', + 'false', + 'false' + ); diff --git a/backend/storage/migrations/2026-06-07.14.00-add-ticket-email-templates.sql b/backend/storage/migrations/2026-06-07.14.00-add-ticket-email-templates.sql new file mode 100644 index 000000000..cce5771ef --- /dev/null +++ b/backend/storage/migrations/2026-06-07.14.00-add-ticket-email-templates.sql @@ -0,0 +1,34 @@ +DELETE FROM featherpanel_mail_templates +WHERE + `featherpanel_mail_templates`.`name` IN ('ticket_replied', 'ticket_closed', 'ticket_reopened'); + +INSERT INTO + `featherpanel_mail_templates` ( + `name`, + `subject`, + `body`, + `deleted`, + `locked` + ) +VALUES + ( + 'ticket_replied', + 'New reply on your ticket: {ticket_title}', + 'Ticket Reply

New Ticket Reply

Support has replied to your ticket

Hello, {first_name} {last_name}

{replier_name} replied to your ticket {ticket_title}.

Reply preview

{reply_preview}

This email was sent to {email}

© 2024-2026 {app_name}. All rights reserved.

Support

', + 'false', + 'false' + ), + ( + 'ticket_closed', + 'Your ticket has been closed: {ticket_title}', + 'Ticket Closed

Ticket Closed

Your support ticket has been marked as closed

Hello, {first_name} {last_name}

Your ticket {ticket_title} has been closed by our support team.

If you still need help, you can reopen the ticket from your dashboard or create a new one.

This email was sent to {email}

© 2024-2026 {app_name}. All rights reserved.

Support

', + 'false', + 'false' + ), + ( + 'ticket_reopened', + 'Your ticket has been reopened: {ticket_title}', + 'Ticket Reopened

Ticket Reopened

Your support ticket is open again

Hello, {first_name} {last_name}

Your ticket {ticket_title} has been reopened. You can continue the conversation from your dashboard.

This email was sent to {email}

© 2024-2026 {app_name}. All rights reserved.

Support

', + 'false', + 'false' + ); diff --git a/backend/storage/migrations/2026-06-07.14.00-add-user-devices.sql b/backend/storage/migrations/2026-06-07.14.00-add-user-devices.sql new file mode 100644 index 000000000..9179875be --- /dev/null +++ b/backend/storage/migrations/2026-06-07.14.00-add-user-devices.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS `featherpanel_user_devices` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_uuid` CHAR(36) NOT NULL, + `device_hash` CHAR(64) NOT NULL, + `signal_hash` CHAR(64) DEFAULT NULL, + `signals` JSON DEFAULT NULL, + `ip_address` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(512) DEFAULT NULL, + `first_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `hit_count` INT NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `user_devices_user_device_unique` (`user_uuid`, `device_hash`), + KEY `user_devices_device_hash_index` (`device_hash`), + KEY `user_devices_signal_hash_index` (`signal_hash`), + KEY `user_devices_user_uuid_index` (`user_uuid`), + CONSTRAINT `user_devices_user_uuid_foreign` + FOREIGN KEY (`user_uuid`) + REFERENCES `featherpanel_users` (`uuid`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/backend/storage/migrations/2026-06-08.12.00-role-custom-badge.sql b/backend/storage/migrations/2026-06-08.12.00-role-custom-badge.sql new file mode 100644 index 000000000..454b0257f --- /dev/null +++ b/backend/storage/migrations/2026-06-08.12.00-role-custom-badge.sql @@ -0,0 +1,2 @@ +ALTER TABLE `featherpanel_roles` +ADD COLUMN `custom_badge` VARCHAR(64) DEFAULT NULL AFTER `color`; diff --git a/backend/storage/modules/pma/auth-page.php b/backend/storage/modules/pma/auth-page.php new file mode 100644 index 000000000..117b465c1 --- /dev/null +++ b/backend/storage/modules/pma/auth-page.php @@ -0,0 +1,461 @@ + + + + + + + Logging in - phpMyAdmin + + + + +
+
+
+ +
+
+ + FeatherPanel + +

Database Management

+
+
+ +
+
+ + +

Authentication error

+

+ +
+

Connecting to phpMyAdmin

+

Authenticating your session securely...

+ +
+ + +
+
+ + + + diff --git a/backend/storage/modules/pma/loading-page.php b/backend/storage/modules/pma/loading-page.php index 95de964fc..117b465c1 100644 --- a/backend/storage/modules/pma/loading-page.php +++ b/backend/storage/modules/pma/loading-page.php @@ -1,163 +1,461 @@ - + - Logging in... - phpMyAdmin + Logging in - phpMyAdmin - - - -
-
-
- -
-
- -
- - - - - -
-
-

FeatherPanel

-
- - -
-
-
-
-
-
-

Logging in to phpMyAdmin

-

Please wait while we authenticate you...

-
-
-
- - -
-

- Powered by FeatherPanel -

-
+ +
+
+
+ +
+
+ + FeatherPanel + +

Database Management

+
+
+ +
+
+ + +

Authentication error

+

+ +
+

Connecting to phpMyAdmin

+

Authenticating your session securely...

+ +
+ +
- diff --git a/backend/storage/modules/pma/token-logout.php b/backend/storage/modules/pma/token-logout.php index f34bfcbe4..898a7c3e2 100644 --- a/backend/storage/modules/pma/token-logout.php +++ b/backend/storage/modules/pma/token-logout.php @@ -1,190 +1,32 @@ . + */ + $session_name = 'TokenSession'; session_name($session_name); @session_start(); session_unset(); session_destroy(); -?> - - - - - - Logging out... - phpMyAdmin - - - - - -
-
- -
-
- - -
-
- -
-
-

FeatherPanel

-

Database Management

-
-
- - -
-
- -
-
- -
- -
- -
-
-
-
-
- - -
-

Logging out

-

Please wait while we securely log you out...

-
- - -
-
-
-
-
- - -
-
-
-
-
-
-
- -
-

- Powered by FeatherPanel -

-
-
-
+$pmaPageMode = 'logout'; +$pmaErrorMessage = null; +$pmaRedirectUrl = null; +$pmaRedirectDelay = 500; +$pmaPostLoadScript = 'setTimeout(function() { window.close(); }, 1000);'; - - - +require __DIR__ . '/auth-page.php'; diff --git a/backend/storage/modules/pma/token.php b/backend/storage/modules/pma/token.php index 447f4574a..2b767d8cc 100644 --- a/backend/storage/modules/pma/token.php +++ b/backend/storage/modules/pma/token.php @@ -1,5 +1,22 @@ . + */ + ini_set('session.use_cookies', 'true'); /* Change this to false if using phpMyAdmin over http */ @@ -24,397 +41,24 @@ @session_write_close(); - // Show loading page then redirect - $redirectUrl = 'index.php?server=1&db=' . urlencode($_GET['db']); - header('Content-Type: text/html; charset=utf-8'); - ?> - - - - - - Logging in... - phpMyAdmin - - - - - -
-
- -
-
- - -
-
- -
-
-

FeatherPanel

-

Database Management

-
-
- - -
-
- -
-
- -
- -
- -
-
-
-
-
- - -
-

Connecting to phpMyAdmin

-

Authenticating your session securely...

-
- - -
-
-
-
-
- - -
-
-
-
-
-
-
- - -
-

- Powered by FeatherPanel -

-
-
-
+ $pmaPageMode = 'connect'; + $pmaErrorMessage = null; + $pmaRedirectUrl = 'index.php?server=1&db=' . urlencode($_GET['db']); + $pmaRedirectDelay = 500; + $pmaPostLoadScript = ''; - - - - - - - - - - <?php echo $errorMessage ? 'Error' : 'Logging in...'; ?> - phpMyAdmin - - - - - -
-
- -
-
- - -
-
- -
-
-

FeatherPanel

-

Database Management

-
-
- - -
- - -
-
-
- - - - - -
-
-
-

Authentication Error

-

-
-
- - -
- -
-
- -
- -
- -
-
-
-
-
- - -
-

Connecting to phpMyAdmin

-

Authenticating your session securely...

-
- - -
-
-
-
-
- - -
-
-
-
-
-
- -
+$pmaPageMode = isset($_SESSION['PMA_single_signon_error_message']) ? 'error' : 'connect'; +$pmaErrorMessage = isset($_SESSION['PMA_single_signon_error_message']) + ? htmlspecialchars($_SESSION['PMA_single_signon_error_message']) + : null; +$pmaRedirectUrl = null; +$pmaRedirectDelay = 500; +$pmaPostLoadScript = ''; - -
-

- Powered by FeatherPanel -

-
-
-
- - - - - +header('Content-Type: text/html; charset=utf-8'); +require __DIR__ . '/auth-page.php'; diff --git a/frontendv2/next.config.ts b/frontendv2/next.config.ts index 1690992fe..1846bf5b3 100644 --- a/frontendv2/next.config.ts +++ b/frontendv2/next.config.ts @@ -15,6 +15,7 @@ See the LICENSE file or . import type { NextConfig } from 'next'; import path from 'path'; +import type { Configuration as WebpackConfiguration } from 'webpack'; const nextConfig: NextConfig = { reactCompiler: true, @@ -24,7 +25,19 @@ const nextConfig: NextConfig = { }, experimental: { - turbopackFileSystemCacheForDev: true, + // Filesystem cache balloons RAM on large apps; use webpack dev by default instead. + turbopackFileSystemCacheForDev: false, + }, + + webpack: (config: WebpackConfiguration, { dev }) => { + if (dev) { + // Keep dev compilation single-threaded to avoid spawning many hungry workers. + config.parallelism = 1; + if (config.cache && typeof config.cache === 'object') { + config.cache = { ...config.cache, maxMemoryGenerations: 1 }; + } + } + return config; }, // Enable standalone output for optimized Docker builds diff --git a/frontendv2/package.json b/frontendv2/package.json index 3ce7f0980..d3b5ee7e7 100644 --- a/frontendv2/package.json +++ b/frontendv2/package.json @@ -1,10 +1,12 @@ { "name": "frontendv2", - "version": "1.3.7+3", + "version": "1.3.7+4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { - "dev": "next dev", + "dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --webpack -H 127.0.0.1", + "dev:turbo": "NODE_OPTIONS='--max-old-space-size=8192' next dev -H 127.0.0.1", + "dev:clean": "rm -rf .next/dev && pnpm dev", "build": "run-s scan:translations lint build:next", "build:next": "next build", "start": "next start", @@ -26,14 +28,14 @@ "@hcaptcha/react-hcaptcha": "^2.0.2", "@headlessui/react": "^2.2.10", "@monaco-editor/react": "4.8.0-rc.3", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-progress": "^1.1.9", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.14", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@simplewebauthn/browser": "13.3.0", - "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/typography": "^0.5.20", "@types/js-yaml": "^4.0.9", "@types/react-google-recaptcha": "^2.1.9", "@xterm/addon-attach": "^0.12.0", @@ -44,22 +46,22 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", - "axios": "^1.16.1", + "axios": "^1.17.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.2.1", + "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", - "js-yaml": "^4.1.1", - "lucide-react": "^1.16.0", - "next": "16.2.6", + "js-yaml": "^4.2.0", + "lucide-react": "^1.17.0", + "next": "16.2.7", "ogl": "^1.0.11", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "prettier-plugin-tailwindcss-canonical-classes": "^0.1.5", - "react": "19.2.6", - "react-dom": "19.2.6", + "react": "19.2.7", + "react-dom": "19.2.7", "react-google-recaptcha": "^3.1.0", "react-markdown": "^10.1.0", - "react-qr-code": "^2.0.21", + "react-qr-code": "^2.2.0", "react-turnstile": "^1.1.5", "recharts": "^3.8.1", "remark-gfm": "^4.0.1", @@ -75,7 +77,7 @@ "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", - "eslint-config-next": "16.2.6", + "eslint-config-next": "16.2.7", "eslint-config-prettier": "^10.1.8", "npm-run-all": "^4.1.5", "prettier-plugin-tailwindcss": "^0.8.0", diff --git a/frontendv2/pnpm-lock.yaml b/frontendv2/pnpm-lock.yaml index 285f6d79f..9fcc4e0ea 100644 --- a/frontendv2/pnpm-lock.yaml +++ b/frontendv2/pnpm-lock.yaml @@ -13,34 +13,34 @@ importers: version: 2.0.2 '@headlessui/react': specifier: ^2.2.10 - version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@monaco-editor/react': specifier: 4.8.0-rc.3 - version: 4.8.0-rc.3(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 4.8.0-rc.3(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^1.1.9 + version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': - specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.14)(react@19.2.6) + specifier: ^1.2.5 + version: 1.2.5(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-switch': - specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^1.3.0 + version: 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-tabs': - specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^1.1.14 + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@react-three/drei': specifier: ^10.7.7 - version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.1)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0) + version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.1)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) '@react-three/fiber': specifier: ^9.6.1 - version: 9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0) + version: 9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) '@simplewebauthn/browser': specifier: 13.3.0 version: 13.3.0 '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.2.4) + specifier: ^0.5.20 + version: 0.5.20(tailwindcss@4.2.4) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -72,8 +72,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 axios: - specifier: ^1.16.1 - version: 1.16.1 + specifier: ^1.17.0 + version: 1.17.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -81,56 +81,56 @@ importers: specifier: ^2.1.1 version: 2.1.1 date-fns: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.4.0 + version: 4.4.0 date-fns-tz: specifier: ^3.2.0 - version: 3.2.0(date-fns@4.2.1) + version: 3.2.0(date-fns@4.4.0) js-yaml: - specifier: ^4.1.1 - version: 4.1.1 + specifier: ^4.2.0 + version: 4.2.0 lucide-react: - specifier: ^1.16.0 - version: 1.16.0(react@19.2.6) + specifier: ^1.17.0 + version: 1.17.0(react@19.2.7) next: - specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 16.2.7 + version: 16.2.7(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ogl: specifier: ^1.0.11 version: 1.0.11 prettier: - specifier: ^3.8.3 - version: 3.8.3 + specifier: ^3.8.4 + version: 3.8.4 prettier-plugin-tailwindcss-canonical-classes: specifier: ^0.1.5 - version: 0.1.5(prettier@3.8.3)(tailwindcss@4.2.4) + version: 0.1.5(prettier@3.8.4)(tailwindcss@4.2.4) react: - specifier: 19.2.6 - version: 19.2.6 + specifier: 19.2.7 + version: 19.2.7 react-dom: - specifier: 19.2.6 - version: 19.2.6(react@19.2.6) + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) react-google-recaptcha: specifier: ^3.1.0 - version: 3.1.0(react@19.2.6) + version: 3.1.0(react@19.2.7) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.6) + version: 10.1.0(@types/react@19.2.14)(react@19.2.7) react-qr-code: - specifier: ^2.0.21 - version: 2.0.21(react@19.2.6) + specifier: ^2.2.0 + version: 2.2.0(react@19.2.7) react-turnstile: specifier: ^1.1.5 - version: 1.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.1.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: specifier: ^3.8.1 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react-is@16.13.1)(react@19.2.7)(redux@5.0.1) remark-gfm: specifier: ^4.0.1 version: 4.0.1 sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -160,8 +160,8 @@ importers: specifier: ^9 version: 9.39.4(jiti@2.7.0) eslint-config-next: - specifier: 16.2.6 - version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + specifier: 16.2.7 + version: 16.2.7(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) @@ -170,7 +170,7 @@ importers: version: 4.1.5 prettier-plugin-tailwindcss: specifier: ^0.8.0 - version: 0.8.0(prettier@3.8.3) + version: 0.8.0(prettier@3.8.4) tailwindcss: specifier: ^4 version: 4.2.4 @@ -585,60 +585,60 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.6': - resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + '@next/env@16.2.7': + resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==} - '@next/eslint-plugin-next@16.2.6': - resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==} + '@next/eslint-plugin-next@16.2.7': + resolution: {integrity: sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w==} - '@next/swc-darwin-arm64@16.2.6': - resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + '@next/swc-darwin-arm64@16.2.7': + resolution: {integrity: sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.6': - resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + '@next/swc-darwin-x64@16.2.7': + resolution: {integrity: sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.6': - resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + '@next/swc-linux-arm64-gnu@16.2.7': + resolution: {integrity: sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.6': - resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + '@next/swc-linux-arm64-musl@16.2.7': + resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.6': - resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + '@next/swc-linux-x64-gnu@16.2.7': + resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.6': - resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + '@next/swc-linux-x64-musl@16.2.7': + resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.6': - resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + '@next/swc-win32-arm64-msvc@16.2.7': + resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.6': - resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + '@next/swc-win32-x64-msvc@16.2.7': + resolution: {integrity: sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -659,11 +659,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + '@radix-ui/react-collection@1.1.9': + resolution: {integrity: sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -675,8 +675,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -684,8 +684,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -693,8 +693,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -702,8 +702,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -711,30 +711,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -746,8 +724,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + '@radix-ui/react-primitive@2.1.5': + resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -759,8 +737,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + '@radix-ui/react-progress@1.1.9': + resolution: {integrity: sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -772,8 +750,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + '@radix-ui/react-roving-focus@1.1.12': + resolution: {integrity: sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -785,8 +763,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + '@radix-ui/react-slot@1.2.5': + resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -794,17 +772,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + '@radix-ui/react-switch@1.3.0': + resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -816,8 +785,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + '@radix-ui/react-tabs@1.1.14': + resolution: {integrity: sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -829,8 +798,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -838,8 +807,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -847,8 +816,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -856,8 +825,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -865,8 +834,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -874,8 +843,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1060,10 +1029,10 @@ packages: '@tailwindcss/postcss@4.2.4': resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' '@tanstack/react-virtual@3.13.24': resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} @@ -1472,8 +1441,8 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -1698,8 +1667,8 @@ packages: peerDependencies: date-fns: ^3.0.0 || ^4.0.0 - date-fns@4.2.1: - resolution: {integrity: sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -1842,8 +1811,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.2.6: - resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==} + eslint-config-next@16.2.7: + resolution: {integrity: sha512-CQ2aNXkrsjaGA2oJBE1LYnlRdphIAQE9ZQfX9hSv1PNGPyiOMSaVeBfTIO29QxYz+ij/hZudK0cfpCG1HXWstg==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2380,6 +2349,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2525,8 +2498,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.16.0: - resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2740,8 +2713,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + next@16.2.7: + resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2976,8 +2949,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} hasBin: true @@ -2998,8 +2971,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qr.js@0.0.0: - resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3015,10 +2988,10 @@ packages: peerDependencies: react: '>=16.4.1' - react-dom@19.2.6: - resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: - react: ^19.2.6 + react: ^19.2.7 react-google-recaptcha@3.1.0: resolution: {integrity: sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==} @@ -3034,8 +3007,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-qr-code@2.0.21: - resolution: {integrity: sha512-xaywjo0eaF4S3LOz6ns5eoPbM2E+q9HYl4VATYpxK4bBniOhQ9noY2RJ9G4SnZFhUwzx63FUT6KdHzfKgUwyuQ==} + react-qr-code@2.2.0: + resolution: {integrity: sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==} peerDependencies: react: '*' @@ -3071,8 +3044,8 @@ packages: react-dom: optional: true - react@19.2.6: - resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} read-pkg@3.0.0: @@ -3832,33 +3805,33 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@floating-ui/react@0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@floating-ui/utils': 0.2.11 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) tabbable: 6.4.0 '@floating-ui/utils@0.2.11': {} '@hcaptcha/react-hcaptcha@2.0.2': {} - '@headlessui/react@2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@headlessui/react@2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-aria/focus': 3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-aria/interactions': 3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-virtual': 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) + '@floating-ui/react': 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/focus': 3.22.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/interactions': 3.28.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) '@humanfs/core@0.19.2': dependencies: @@ -4017,12 +3990,12 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.8.0-rc.3(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@monaco-editor/react@4.8.0-rc.3(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@monaco-editor/loader': 1.7.0 monaco-editor: 0.55.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@monogrid/gainmap-js@3.4.0(three@0.184.0)': dependencies: @@ -4036,34 +4009,34 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@next/env@16.2.6': {} + '@next/env@16.2.7': {} - '@next/eslint-plugin-next@16.2.6': + '@next/eslint-plugin-next@16.2.7': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.6': + '@next/swc-darwin-arm64@16.2.7': optional: true - '@next/swc-darwin-x64@16.2.6': + '@next/swc-darwin-x64@16.2.7': optional: true - '@next/swc-linux-arm64-gnu@16.2.6': + '@next/swc-linux-arm64-gnu@16.2.7': optional: true - '@next/swc-linux-arm64-musl@16.2.6': + '@next/swc-linux-arm64-musl@16.2.7': optional: true - '@next/swc-linux-x64-gnu@16.2.6': + '@next/swc-linux-x64-gnu@16.2.7': optional: true - '@next/swc-linux-x64-musl@16.2.6': + '@next/swc-linux-x64-musl@16.2.7': optional: true - '@next/swc-win32-arm64-msvc@16.2.6': + '@next/swc-win32-arm64-msvc@16.2.7': optional: true - '@next/swc-win32-x64-msvc@16.2.6': + '@next/swc-win32-x64-msvc@16.2.7': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4080,213 +4053,190 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.4': {} - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-collection@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-context@1.1.4(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-direction@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-id@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-progress@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-slot@1.2.5(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-switch@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-tabs@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@react-aria/focus@3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@react-aria/focus@3.22.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@swc/helpers': 0.5.21 - react: 19.2.6 - react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-aria: 3.48.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) - '@react-aria/interactions@3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@react-aria/interactions@3.28.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.34.0(react@19.2.7) '@swc/helpers': 0.5.21 - react: 19.2.6 - react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-aria: 3.48.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) - '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.1)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)': + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.1)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0)': dependencies: '@babel/runtime': 7.29.2 '@mediapipe/tasks-vision': 0.10.17 '@monogrid/gainmap-js': 3.4.0(three@0.184.0) - '@react-three/fiber': 9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0) - '@use-gesture/react': 10.3.1(react@19.2.6) + '@react-three/fiber': 9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) + '@use-gesture/react': 10.3.1(react@19.2.7) camera-controls: 3.1.2(three@0.184.0) cross-env: 7.0.3 detect-gpu: 5.0.70 @@ -4294,50 +4244,50 @@ snapshots: hls.js: 1.6.16 maath: 0.10.8(@types/three@0.184.1)(three@0.184.0) meshline: 3.3.1(three@0.184.0) - react: 19.2.6 + react: 19.2.7 stats-gl: 2.4.2(@types/three@0.184.1)(three@0.184.0) stats.js: 0.17.0 - suspend-react: 0.1.3(react@19.2.6) + suspend-react: 0.1.3(react@19.2.7) three: 0.184.0 three-mesh-bvh: 0.8.3(three@0.184.0) three-stdlib: 2.36.1(three@0.184.0) troika-three-text: 0.52.4(three@0.184.0) - tunnel-rat: 0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) + tunnel-rat: 0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) utility-types: 3.11.0 - zustand: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + zustand: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) optionalDependencies: - react-dom: 19.2.6(react@19.2.6) + react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - '@types/react' - '@types/three' - immer - '@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)': + '@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0)': dependencies: '@babel/runtime': 7.29.2 '@types/webxr': 0.5.24 base64-js: 1.5.1 buffer: 6.0.3 - its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.6) - react: 19.2.6 - react-use-measure: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-use-measure: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) scheduler: 0.27.0 - suspend-react: 0.1.3(react@19.2.6) + suspend-react: 0.1.3(react@19.2.7) three: 0.184.0 - use-sync-external-store: 1.6.0(react@19.2.6) - zustand: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + use-sync-external-store: 1.6.0(react@19.2.7) + zustand: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) optionalDependencies: - react-dom: 19.2.6(react@19.2.6) + react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - '@types/react' - immer - '@react-types/shared@3.34.0(react@19.2.6)': + '@react-types/shared@3.34.0(react@19.2.7)': dependencies: - react: 19.2.6 + react: 19.2.7 - '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 @@ -4346,8 +4296,8 @@ snapshots: redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 19.2.6 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + react: 19.2.7 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1) '@rtsao/scc@1.1.0': {} @@ -4463,16 +4413,16 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.2.4 - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': + '@tailwindcss/typography@0.5.20(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.4 - '@tanstack/react-virtual@3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.14.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@tanstack/virtual-core@3.14.0': {} @@ -4739,10 +4689,10 @@ snapshots: '@use-gesture/core@10.3.1': {} - '@use-gesture/react@10.3.1(react@19.2.6)': + '@use-gesture/react@10.3.1(react@19.2.7)': dependencies: '@use-gesture/core': 10.3.1 - react: 19.2.6 + react: 19.2.7 '@xterm/addon-attach@0.12.0': {} @@ -4876,7 +4826,7 @@ snapshots: axe-core@4.11.4: {} - axios@1.16.1: + axios@1.17.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 @@ -5092,11 +5042,11 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns-tz@3.2.0(date-fns@4.2.1): + date-fns-tz@3.2.0(date-fns@4.4.0): dependencies: - date-fns: 4.2.1 + date-fns: 4.4.0 - date-fns@4.2.1: {} + date-fns@4.4.0: {} debug@3.2.7: dependencies: @@ -5286,9 +5236,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.2.7(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.2.6 + '@next/eslint-plugin-next': 16.2.7 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) @@ -5885,10 +5835,10 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - its-fine@2.0.0(@types/react@19.2.14)(react@19.2.6): + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.7): dependencies: '@types/react-reconciler': 0.28.9(@types/react@19.2.14) - react: 19.2.6 + react: 19.2.7 transitivePeerDependencies: - '@types/react' @@ -5902,6 +5852,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6021,9 +5975,9 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.16.0(react@19.2.6): + lucide-react@1.17.0(react@19.2.7): dependencies: - react: 19.2.6 + react: 19.2.7 maath@0.10.8(@types/three@0.184.1)(three@0.184.0): dependencies: @@ -6430,25 +6384,25 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@16.2.7(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@next/env': 16.2.6 + '@next/env': 16.2.7 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.27 caniuse-lite: 1.0.30001792 postcss: 8.4.31 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 + '@next/swc-darwin-arm64': 16.2.7 + '@next/swc-darwin-x64': 16.2.7 + '@next/swc-linux-arm64-gnu': 16.2.7 + '@next/swc-linux-arm64-musl': 16.2.7 + '@next/swc-linux-x64-gnu': 16.2.7 + '@next/swc-linux-x64-musl': 16.2.7 + '@next/swc-win32-arm64-msvc': 16.2.7 + '@next/swc-win32-x64-msvc': 16.2.7 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -6630,17 +6584,17 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss-canonical-classes@0.1.5(prettier@3.8.3)(tailwindcss@4.2.4): + prettier-plugin-tailwindcss-canonical-classes@0.1.5(prettier@3.8.4)(tailwindcss@4.2.4): dependencies: '@laststance/tailwindcss-canonical-classes-core': 0.1.0(tailwindcss@4.2.4) - prettier: 3.8.3 + prettier: 3.8.4 tailwindcss: 4.2.4 - prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.4): dependencies: - prettier: 3.8.3 + prettier: 3.8.4 - prettier@3.8.3: {} + prettier@3.8.4: {} promise-worker-transferable@1.0.4: dependencies: @@ -6659,44 +6613,44 @@ snapshots: punycode@2.3.1: {} - qr.js@0.0.0: {} + qrcode-generator@2.0.4: {} queue-microtask@1.2.3: {} - react-aria@3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-aria@3.48.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@internationalized/date': 3.12.1 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.34.0(react@19.2.7) '@swc/helpers': 0.5.21 aria-hidden: 1.2.6 clsx: 2.1.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-stately: 3.46.0(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.46.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) - react-async-script@1.2.0(react@19.2.6): + react-async-script@1.2.0(react@19.2.7): dependencies: hoist-non-react-statics: 3.3.2 prop-types: 15.8.1 - react: 19.2.6 + react: 19.2.7 - react-dom@19.2.6(react@19.2.6): + react-dom@19.2.7(react@19.2.7): dependencies: - react: 19.2.6 + react: 19.2.7 scheduler: 0.27.0 - react-google-recaptcha@3.1.0(react@19.2.6): + react-google-recaptcha@3.1.0(react@19.2.7): dependencies: prop-types: 15.8.1 - react: 19.2.6 - react-async-script: 1.2.0(react@19.2.6) + react: 19.2.7 + react-async-script: 1.2.0(react@19.2.7) react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.6): + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.7): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -6705,7 +6659,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.6 + react: 19.2.7 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -6714,43 +6668,43 @@ snapshots: transitivePeerDependencies: - supports-color - react-qr-code@2.0.21(react@19.2.6): + react-qr-code@2.2.0(react@19.2.7): dependencies: prop-types: 15.8.1 - qr.js: 0.0.0 - react: 19.2.6 + qrcode-generator: 2.0.4 + react: 19.2.7 - react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1): + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 redux: 5.0.1 - react-stately@3.46.0(react@19.2.6): + react-stately@3.46.0(react@19.2.7): dependencies: '@internationalized/date': 3.12.1 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.34.0(react@19.2.7) '@swc/helpers': 0.5.21 - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) - react-turnstile@1.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-turnstile@1.1.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - react-use-measure@2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.6 + react: 19.2.7 optionalDependencies: - react-dom: 19.2.6(react@19.2.6) + react-dom: 19.2.7(react@19.2.7) - react@19.2.6: {} + react@19.2.7: {} read-pkg@3.0.0: dependencies: @@ -6758,21 +6712,21 @@ snapshots: normalize-package-data: 2.5.0 path-type: 3.0.0 - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react-is@16.13.1)(react@19.2.7)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1))(react@19.2.7) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.46.1 eventemitter3: 5.0.4 immer: 10.2.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) react-is: 16.13.1 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.7) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -6995,10 +6949,10 @@ snapshots: sift-string@0.0.2: {} - sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) source-map-js@1.2.1: {} @@ -7114,10 +7068,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.6): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.7): dependencies: client-only: 0.0.1 - react: 19.2.6 + react: 19.2.7 optionalDependencies: '@babel/core': 7.29.0 @@ -7131,9 +7085,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - suspend-react@0.1.3(react@19.2.6): + suspend-react@0.1.3(react@19.2.7): dependencies: - react: 19.2.6 + react: 19.2.7 tabbable@6.4.0: {} @@ -7203,9 +7157,9 @@ snapshots: tslib@2.8.1: {} - tunnel-rat@0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6): + tunnel-rat@0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7): dependencies: - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7) transitivePeerDependencies: - '@types/react' - immer @@ -7341,9 +7295,9 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.6): + use-sync-external-store@1.6.0(react@19.2.7): dependencies: - react: 19.2.6 + react: 19.2.7 util-deprecate@1.0.2: {} @@ -7463,19 +7417,19 @@ snapshots: zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6): + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7): dependencies: - use-sync-external-store: 1.6.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 immer: 11.1.7 - react: 19.2.6 + react: 19.2.7 - zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.14 immer: 11.1.7 - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) zwitch@2.0.4: {} diff --git a/frontendv2/pnpm-workspace.yaml b/frontendv2/pnpm-workspace.yaml index f08956d40..83928874b 100644 --- a/frontendv2/pnpm-workspace.yaml +++ b/frontendv2/pnpm-workspace.yaml @@ -1,6 +1,9 @@ allowBuilds: sharp: true unrs-resolver: true +minimumReleaseAgeExclude: + - prettier@3.8.4 + - react-qr-code@2.2.0 onlyBuiltDependencies: - '@scarf/scarf' - '@tree-sitter-grammars/tree-sitter-yaml' diff --git a/frontendv2/public/icanhasfeatherpanel/index.html b/frontendv2/public/icanhasfeatherpanel/index.html index f869ac932..65f03fbed 100644 --- a/frontendv2/public/icanhasfeatherpanel/index.html +++ b/frontendv2/public/icanhasfeatherpanel/index.html @@ -34,7 +34,7 @@

FeatherPanel Documentation

Widgets

Explore all available widget injection points and learn how to create custom widgets for FeatherPanel.

- 125 widget slugs available + 127 widget slugs available
diff --git a/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-create.html b/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-create.html new file mode 100644 index 000000000..05c1f575a --- /dev/null +++ b/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-create.html @@ -0,0 +1,87 @@ + + + + + Widget: admin-roles-create - FeatherPanel + + + + +
+ ← Back to Widgets +
+

admin-roles-create

+

Widget slug and injection point details

+
+ +
+

Injection Points

+

Available injection points for this widget slug:

+
    +
  • bottom-of-page
  • +
  • top-of-page
  • +
+
+ +
+

Source Files

+

Files where this widget slug is used:

+
    +
  • page.tsx (src/app/(app)/admin/roles/create/page.tsx)
  • +
+
+ +
+

Plugin Widget Integration

+

Widget Configuration

+

To inject a widget into this page, create a widgets.json file in your plugin's Frontend/ directory:

+
{
+  "id": "my-plugin-widget",
+  "component": "my-widget.html",
+  "enabled": true,
+  "priority": 100,
+  "page": "admin-roles-create",
+  "location": "bottom-of-page",
+  "size": "full"
+}
+ +

Available Injection Points

+

This page supports the following injection points. Use the location property in your widget configuration:

+
    +
  • bottom-of-page
  • +
  • top-of-page
  • +
+ +

Widget Sizing

+

Set the size property to control widget width:

+
    +
  • "full" - Full width (default)
  • +
  • "half" - Half width (2 per row)
  • +
  • "third" - One-third width (3 per row)
  • +
  • "quarter" - One-quarter width (4 per row)
  • +
+ +

Widget Context

+

Widgets automatically receive context information accessible via:

+
const context = window.FeatherPanel?.widgetContext || {};
+const userUuid = context.userUuid;
+const serverUuid = context.serverUuid;
+
+
+ + diff --git a/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-edit.html b/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-edit.html new file mode 100644 index 000000000..8f9e074e7 --- /dev/null +++ b/frontendv2/public/icanhasfeatherpanel/widgets/admin-roles-edit.html @@ -0,0 +1,87 @@ + + + + + Widget: admin-roles-edit - FeatherPanel + + + + +
+ ← Back to Widgets +
+

admin-roles-edit

+

Widget slug and injection point details

+
+ +
+

Injection Points

+

Available injection points for this widget slug:

+
    +
  • bottom-of-page
  • +
  • top-of-page
  • +
+
+ +
+

Source Files

+

Files where this widget slug is used:

+
    +
  • page.tsx (src/app/(app)/admin/roles/[id]/edit/page.tsx)
  • +
+
+ +
+

Plugin Widget Integration

+

Widget Configuration

+

To inject a widget into this page, create a widgets.json file in your plugin's Frontend/ directory:

+
{
+  "id": "my-plugin-widget",
+  "component": "my-widget.html",
+  "enabled": true,
+  "priority": 100,
+  "page": "admin-roles-edit",
+  "location": "bottom-of-page",
+  "size": "full"
+}
+ +

Available Injection Points

+

This page supports the following injection points. Use the location property in your widget configuration:

+
    +
  • bottom-of-page
  • +
  • top-of-page
  • +
+ +

Widget Sizing

+

Set the size property to control widget width:

+
    +
  • "full" - Full width (default)
  • +
  • "half" - Half width (2 per row)
  • +
  • "third" - One-third width (3 per row)
  • +
  • "quarter" - One-quarter width (4 per row)
  • +
+ +

Widget Context

+

Widgets automatically receive context information accessible via:

+
const context = window.FeatherPanel?.widgetContext || {};
+const userUuid = context.userUuid;
+const serverUuid = context.serverUuid;
+
+
+ + diff --git a/frontendv2/public/icanhasfeatherpanel/widgets/admin-servers.html b/frontendv2/public/icanhasfeatherpanel/widgets/admin-servers.html index 8d48b7c51..576360b99 100644 --- a/frontendv2/public/icanhasfeatherpanel/widgets/admin-servers.html +++ b/frontendv2/public/icanhasfeatherpanel/widgets/admin-servers.html @@ -35,7 +35,6 @@

Injection Points

  • after-header
  • before-list
  • -
  • bottom-of-page
  • top-of-page
@@ -67,7 +66,6 @@

Available Injection Points

  • after-header
  • before-list
  • -
  • bottom-of-page
  • top-of-page
diff --git a/frontendv2/public/icanhasfeatherpanel/widgets/index.html b/frontendv2/public/icanhasfeatherpanel/widgets/index.html index 2126fb669..4f8d3958d 100644 --- a/frontendv2/public/icanhasfeatherpanel/widgets/index.html +++ b/frontendv2/public/icanhasfeatherpanel/widgets/index.html @@ -27,8 +27,8 @@

Widget Injection Points

All available widget slugs and their injection points in FeatherPanel. Click on any widget to view detailed information.

- 125 widget slugs - 412 total injection points + 127 widget slugs + 415 total injection points
@@ -383,11 +383,25 @@

admin-roles

Injection Points: after-header before-list bottom-of-page +1 more

+
  • + +

    admin-roles-create

    +

    1 source file

    +

    Injection Points: bottom-of-page top-of-page

    +
    +
  • +
  • + +

    admin-roles-edit

    +

    1 source file

    +

    Injection Points: bottom-of-page top-of-page

    +
    +
  • admin-servers

    1 source file

    -

    Injection Points: after-header before-list bottom-of-page +1 more

    +

    Injection Points: after-header before-list top-of-page

  • diff --git a/frontendv2/public/locales/en.json b/frontendv2/public/locales/en.json index f4940d0eb..30122d73f 100644 --- a/frontendv2/public/locales/en.json +++ b/frontendv2/public/locales/en.json @@ -1,5 +1,10 @@ { "auth": { + "legal": { + "login_prefix": "By continuing, you agree to our", + "register_prefix": "By creating an account, you agree to our", + "and": "and" + }, "login": { "title": "Welcome back", "subtitle": "Enter your credentials to access your account", @@ -97,7 +102,9 @@ "last_name_placeholder": "Doe", "email_placeholder": "john@example.com", "username_placeholder": "johndoe", - "password_placeholder": "Create a strong password" + "password_placeholder": "Create a strong password", + "device_limit": "Too many accounts on this device. Please use your main account: {{username}}, or contact support.", + "device_limit_generic": "Too many accounts on this device. Please use your main account or contact support." }, "forgot_password": { "title": "Reset your password", @@ -330,6 +337,21 @@ "copyright": "{company}", "powered_by": "Powered by FeatherPanel" }, + "links": { + "aria_label": "Panel links", + "website": "Website", + "support": "Support", + "discord": "Discord", + "linkedin": "LinkedIn", + "telegram": "Telegram", + "tiktok": "TikTok", + "twitter": "X / Twitter", + "whatsapp": "WhatsApp", + "youtube": "YouTube", + "status": "Status", + "terms": "Terms of Service", + "privacy": "Privacy Policy" + }, "maintenance": { "title": "System Maintenance", "message": "We are currently upgrading our systems to provide you with a better experience. We'll be back shortly.", @@ -542,6 +564,14 @@ "linkedAs": "Linked as", "discordUnlinkedSuccessfully": "Discord account unlinked successfully", "discordUnlinkFailed": "Failed to unlink Discord account", + "discordLinkedSuccessfully": "Discord account linked successfully", + "discordLinkFailed": "Failed to link Discord account", + "discordAlreadyLinked": "This Discord account is already linked to another FeatherPanel user.", + "discordDisabled": "Discord login is disabled on this panel.", + "discordNotConfigured": "Discord OAuth is not configured correctly.", + "discordTokenFailed": "Discord authorization failed. Please try again.", + "discordUserFailed": "Could not retrieve your Discord profile. Please try again.", + "discordUserNotFound": "Your FeatherPanel account could not be found. Please log in and try again.", "oidcAccount": "OIDC Account", "oidcAccountDescription": "Link an external single sign-on account to this FeatherPanel account.", "linkOidc": "Link OIDC", @@ -760,7 +790,10 @@ }, "showingMails": "Showing {from} to {to} of {total} emails", "totalMailsCount": "{count} emails", - "mailContent": "Mail Content" + "mailContent": "Mail Content", + "resend": "Resend", + "resendSuccess": "Email queued for resend", + "resendFailed": "Failed to resend email" }, "licenses": {}, "twoFactor": { @@ -818,7 +851,8 @@ "installing": "Installing", "install_failed": "Install Failed", "suspended": "Suspended", - "restoring_backup": "Restoring Backup" + "restoring_backup": "Restoring Backup", + "transferring": "Transferring" }, "suspended_banner": { "title": "Server Suspended", @@ -1126,7 +1160,17 @@ "priority": "Priority", "server": "Server", "serverId": "ID", - "attachFiles": "Attach Files" + "attachFiles": "Attach Files", + "adminOpenTicketsTitle": "Open support tickets", + "adminOpenTicketsDescription": "There are {count} open ticket(s) waiting for staff. Please review them in the admin panel.", + "adminViewOpenTickets": "View open tickets", + "adminManageTickets": "Manage in admin", + "adminViewAndManage": "Review all open support tickets as staff", + "adminOpenTicketsInline": "{count} open ticket(s) system-wide", + "adminScopeOpen": "All open", + "adminScopeAll": "All tickets", + "adminScopeMine": "My tickets", + "adminDismissOpenTicketsBanner": "Dismiss for now" }, "vms": { "title": "Virtual Machines", @@ -1629,6 +1673,19 @@ "noDatabaseHostsDescription": "No database hosts are configured. Please contact an administrator to set up database hosts.", "failedToOpenPhpMyAdmin": "Failed to open phpMyAdmin", "openingPhpMyAdmin": "Opening phpMyAdmin...", + "pmaAuth": { + "pageTitleLogin": "Logging in - phpMyAdmin", + "pageTitleError": "Authentication error - phpMyAdmin", + "pageTitleLogout": "Logging out - phpMyAdmin", + "databaseManagement": "Database Management", + "connecting": "Connecting to phpMyAdmin", + "authenticating": "Authenticating your session securely...", + "loggingOut": "Logging out", + "loggingOutMessage": "Please wait while we securely log you out...", + "authError": "Authentication error", + "poweredBy": "Powered by {name}", + "loading": "Loading" + }, "noHostSelected": "Please select a database host", "allHosts": "All Hosts", "exportSql": "Export SQL", @@ -2426,6 +2483,11 @@ "startupHelp": "Edit the command that will be used to start the server.", "dockerImage": "Docker Image", "availableImages": "Available Docker Images", + "spellDefaultDockerImage": "spell default", + "dockerImageListOnlyHelp": "Select one of the images below. Custom Docker images are disabled by your administrator.", + "dockerImageMustBeFromList": "Docker image must be selected from the list configured for this spell.", + "noDockerImageSelected": "No Docker image selected", + "noDockerImagesConfigured": "No Docker images are configured for this spell.", "variables": "Startup Variables", "variablesHelp": "Configure environmental variables for your server.", "restoreDefault": "Restore Default", @@ -4120,28 +4182,64 @@ "delete_failed": "Failed to delete role", "permission_added": "Permission added successfully", "permission_removed": "Permission removed successfully", - "permission_failed": "Failed to update permissions" + "permission_failed": "Failed to update permissions", + "cannot_remove_own_admin_root": "You cannot remove admin.root from your own role — that would lock you out of the panel.", + "duplicated": "Role duplicated successfully", + "duplicate_failed": "Failed to duplicate role" }, "form": { "create_title": "Create Role", "edit_title": "Edit Role", - "name": "Name", + "name": "Internal Name", "display_name": "Display Name", + "custom_badge": "Custom Badge", + "custom_badge_hint": "Optional short label shown on user badges. Leave empty to use the display name.", "color": "Color", "submit_create": "Create Role", - "submit_update": "Update Role", + "submit_update": "Save Changes", "permissions": "Permissions", - "no_permissions": "No permissions assigned" + "no_permissions": "No permissions assigned", + "auto_name_hint": "Auto-generated from the display name. Edit only if you need a custom slug.", + "random_color": "Random" + }, + "tabs": { + "details": "Details", + "permissions": "Permissions" + }, + "filters": { + "all": "All", + "granted": "Granted", + "missing": "Not granted" + }, + "stats": { + "assigned": "Granted", + "total": "Available", + "coverage": "Coverage", + "role_count": "{count} roles", + "permission_count": "{count} permissions" + }, + "actions": { + "duplicate": "Duplicate" }, "permissions": { - "title": "Manage Permissions", - "description": "Add or remove permissions for this role.", + "title": "Permissions", + "description": "Toggle permissions on or off for this role.", "search": "Search permissions...", - "syncing": "Syncing..." + "syncing": "Syncing...", + "create_first_hint": "Save the role first, then assign permissions.", + "admin_root_locked": "Locked on your role so you cannot remove your own full admin access.", + "locked": "Locked", + "in_category": "granted", + "expand_all": "Expand all", + "collapse_all": "Collapse all", + "enable_category": "Enable all", + "disable_category": "Disable all" }, "pagination": {}, "labels": { - "created": "Created" + "created": "Created", + "your_role": "Your role", + "preview": "Preview" }, "help": { "managing": { @@ -5582,6 +5680,8 @@ "select_allocation": "Select destination allocation...", "auto_allocate": "Assign allocation automatically", "auto_allocate_help": "Uses the next free allocation(s) on the destination node for the primary port and any extra ports.", + "auto_open_ports": "Auto-open missing ports on destination", + "auto_open_ports_help": "If the destination node has no free allocations, create them using IPs from Wings and the same ports this server currently uses.", "search_nodes": "Search nodes...", "search_allocations": "Search allocations...", "no_free_allocations": "No free allocations found", @@ -5597,7 +5697,12 @@ "beta_item8": "Network issues can cause partial transfers or data corruption.", "fetch_nodes_failed": "Failed to fetch destination nodes", "fetch_allocations_failed": "Failed to fetch destination allocations", - "submit": "I Understand - Start Transfer", + "allocations_needed": "Allocations needed", + "free_on_destination": "Free on destination", + "insufficient_allocations": "Not enough free allocations on the destination node (need {needed}, found {found}). Add more allocations to the destination node or pick a different node.", + "insufficient_allocations_hint": "Enable \"Auto-open missing ports on destination\" to create allocations from the Wings IP list automatically.", + "error_title": "Transfer failed", + "submit": "Start Transfer", "submitting": "Starting Transfer..." }, "form": { @@ -5670,6 +5775,7 @@ "docker_image_help": "Select or enter the Docker image for this spell. This will be used to deploy the server.", "select_docker_image": "Select Docker image...", "available_docker_images": "Available from spell", + "spell_default_docker_image": "spell default", "memory": "Memory (MiB)", "memory_help": "The maximum amount of memory allowed for this container. Setting this to 0 will allow unlimited memory usage.", "swap": "Swap (MiB)", @@ -5756,7 +5862,12 @@ "realm_required": "Realm is required", "spell_required": "Spell is required", "docker_image_required": "Docker image is required", - "startup_required": "Startup command is required" + "startup_required": "Startup command is required", + "memory_limit": "Memory must be unlimited (0) or between 128 MiB and 1 TiB", + "swap_limit": "Swap must be disabled (0), unlimited (-1), or between 1 MiB and 1 TiB", + "disk_limit": "Disk must be unlimited (0) or between 128 MiB and 10 TiB", + "cpu_limit": "CPU must be unlimited (0) or between 1% and 1,000,000%", + "io_limit": "IO weight must be between 10 and 1000" } }, "messages": { @@ -5816,6 +5927,7 @@ "startup": { "title": "Startup Configuration", "description": "Configure the startup command for this server", + "docker_image_description": "Select the Docker image used when this server installs and runs. The starred spell default is applied for new servers.", "available_variables": "Available Variables:" }, "mounts": { @@ -6898,6 +7010,10 @@ "subtitle": "Manage all users in your system", "search_placeholder": "Search by username, email, or role...", "last_seen": "Last seen", + "clear_all_devices": "Clear all device fingerprints", + "clear_all_devices_confirm": "Clear every browser/device fingerprint record in the panel? This resets alt detection and registration device limits until users browse again.", + "clear_all_devices_success": "All device fingerprint records cleared", + "clear_all_devices_failed": "Failed to clear device fingerprint records", "table": {}, "actions": { "force_verify_email": "Force verify email" @@ -6942,7 +7058,23 @@ "all_roles": "All Roles", "any_status": "Any Status", "status_active": "Only active users", - "status_banned": "Only banned users" + "status_banned": "Only banned users", + "any_email_status": "Any email status", + "email_verified": "Email verified", + "email_unverified": "Email unverified", + "ip_placeholder": "Filter by IP..." + }, + "sort": { + "created_desc": "Recently created", + "created_asc": "Oldest created", + "last_active_desc": "Recently active", + "last_active_asc": "Least recently active", + "newest": "Newest first", + "oldest": "Oldest first", + "username_asc": "Username A–Z", + "username_desc": "Username Z–A", + "email_asc": "Email A–Z", + "email_desc": "Email Z–A" }, "common": {}, "badges": { @@ -6989,6 +7121,7 @@ "servers": "Servers", "vds": "VDS", "activities": "Activities", + "potential_alts": "Potential Alts", "mails": "Mails" }, "form": { @@ -7062,6 +7195,38 @@ "created": "Created At", "no_activities": "No activity logs found." }, + "potential_alts": { + "title": "Potential Alt Accounts", + "description": "These users may be alternate accounts based on shared IP addresses (panel activity, server activity, first/last IP) and browser sync identifiers collected across the panel. Matches are indicative only — shared networks, VPNs, and family devices can produce false positives.", + "source_ips": "IPs used by this account", + "source_devices": "Browser sync IDs seen for this account", + "user": "User", + "role": "Role", + "shared_ips": "Shared IPs", + "signals": "Matched signals", + "confidence": "Confidence", + "confidence_high": "High", + "confidence_medium": "Medium", + "confidence_low": "Low", + "matches": "Matches", + "match_total": "signals", + "match_ip": "Shared IP address", + "match_device": "Shared browser/device sync ID", + "last_seen": "Last Seen", + "actions": "Actions", + "empty": "No other users share IP addresses or browser sync IDs with this account.", + "reasons": { + "panel_activity": "Panel activity IP", + "server_activity": "Server activity IP", + "user_ip": "Account IP", + "device_sync": "Browser sync ID", + "device_profile": "Browser profile" + }, + "clear_user": "Clear device fingerprints", + "clear_user_confirm": "Clear all browser/device fingerprint records for {{username}}? They can register again from the same device after this.", + "clear_user_success": "Device fingerprints cleared for this user", + "clear_failed": "Failed to clear device fingerprints" + }, "mails": { "title": "Mails", "subject": "Subject", @@ -7069,6 +7234,9 @@ "created": "Created At", "preview": "Preview", "actions": "Actions", + "resend": "Resend", + "resend_success": "Email queued for resend", + "resend_failed": "Failed to resend email", "no_mails": "No mails found." } }, @@ -7377,6 +7545,10 @@ } }, "fields": { + "discord_url": { + "label": "Discord URL", + "description": "Discord invite or community server URL shown in the panel footer." + }, "captcha_provider": { "label": "Captcha Provider", "description": "Choose which captcha service to use: Cloudflare Turnstile, hCaptcha, Google reCAPTCHA, Friendly Captcha, or reForge Captcha.", @@ -7492,6 +7664,22 @@ "label": "Require 2FA for Admins", "description": "Force all administrators to enable Two-Factor Authentication." }, + "avatar_provider": { + "label": "Default Avatar Provider", + "description": "Choose which service generates profile pictures for users without a custom avatar.", + "options": { + "gravatar": "Gravatar (WordPress-style)", + "panel_logo": "Panel logo", + "ui_avatars": "UI Avatars (initials)", + "robohash": "RoboHash", + "dicebear": "DiceBear (initials)", + "custom": "Custom URL template" + } + }, + "avatar_custom_url": { + "label": "Custom Avatar URL Template", + "description": "Only used when the avatar provider is Custom. Placeholders: {email}, {username}, {name}, {hash}, {app_url}." + }, "user_allow_avatar_change": { "label": "Allow Avatar Change", "description": "Allow users to upload and change their own profile avatars." @@ -8118,7 +8306,7 @@ "no_schema_desc": "The plugin developer needs to add a config section to conf.yml", "spell_restrictions": { "title": "Server Sidebar Spell Restrictions", - "description": "Configure which server types (spells) this plugin should appear on. Leave empty to show on all server types.", + "description": "Allow-list only: select the server types (spells) where this plugin should appear in the sidebar. Leave empty to show on all server types. To hide the plugin from specific eggs, select every spell except those — do not select only the eggs you want to block.", "loading": "Loading spells...", "no_spells": "No spells found", "save": "Save Spell Restrictions", @@ -8410,6 +8598,8 @@ "server_configuration": "Server Configuration", "installation_startup_scripts": "Installation & Startup Scripts", "docker_images": "Docker Images", + "default_docker_image_help": "Click the star on the image that should be selected by default when creating new servers. Update this when game runtimes change (e.g. newer Java versions).", + "set_default_docker_image": "Set as default install image", "add_docker_image": "Add Docker Image", "script_container": "Script Container", "script_entry": "Script Entry", diff --git a/frontendv2/src/app/(app)/admin/images/page.tsx b/frontendv2/src/app/(app)/admin/images/page.tsx index ac4d2231f..eccfacf1e 100644 --- a/frontendv2/src/app/(app)/admin/images/page.tsx +++ b/frontendv2/src/app/(app)/admin/images/page.tsx @@ -43,6 +43,7 @@ import { EmptyState } from '@/components/featherui/EmptyState'; import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Label } from '@/components/ui/label'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface Image { @@ -63,20 +64,29 @@ interface Pagination { to: number; } +const IMAGES_LIST_FILTERS_KEY = 'featherpanel_admin_images_filters_v1'; +const IMAGES_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + export default function ImagesPage() { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [images, setImages] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const { filters, patchFilters, hydrated } = usePersistedListFilters( + IMAGES_LIST_FILTERS_KEY, + IMAGES_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const [pagination, setPagination] = useState>({ total: 0, hasNext: false, hasPrev: false, from: 0, to: 0, }); - const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [createOpen, setCreateOpen] = useState(false); @@ -98,19 +108,25 @@ export default function ImagesPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); + + const totalPages = Math.ceil(pagination.total / pageSize) || 1; const fetchImages = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get('/api/admin/images', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, }); @@ -118,8 +134,6 @@ export default function ImagesPage() { setImages(data.data.images || []); const apiPag = data.data.pagination; setPagination({ - page: apiPag.current_page, - pageSize: apiPag.per_page, total: apiPag.total_records, hasNext: apiPag.has_next, hasPrev: apiPag.has_prev, @@ -135,7 +149,7 @@ export default function ImagesPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, t]); + }, [page, pageSize, debouncedSearchQuery, t, hydrated]); useEffect(() => { fetchImages(); @@ -279,33 +293,33 @@ export default function ImagesPage() { className='h-11 w-full pl-10' placeholder={t('admin.images.search_placeholder')} value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} />
  • - {pagination.total > pagination.pageSize && ( + {pagination.total > pageSize && (
    - {pagination.page} / {Math.ceil(pagination.total / pagination.pageSize)} + {page} / {totalPages}
    - {pagination.total > pagination.pageSize && ( + {pagination.total > pageSize && (
    - {pagination.page} / {Math.ceil(pagination.total / pagination.pageSize)} + {page} / {totalPages} diff --git a/frontendv2/src/app/(app)/admin/locations/page.tsx b/frontendv2/src/app/(app)/admin/locations/page.tsx index 8c243dfad..1e4461cc2 100644 --- a/frontendv2/src/app/(app)/admin/locations/page.tsx +++ b/frontendv2/src/app/(app)/admin/locations/page.tsx @@ -30,6 +30,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFo import { Select } from '@/components/ui/select-native'; import { Label } from '@/components/ui/label'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -77,6 +78,13 @@ interface Pagination { /** Set to false to allow selecting VPS and Web hosting (Proxmox / FeatherFly). */ const NO_WEBHOSTING = true; +const LOCATIONS_LIST_FILTERS_KEY = 'featherpanel_admin_locations_filters_v1'; +const LOCATIONS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + const LOCATION_TYPES: { value: LocationType; icon: React.ComponentType<{ className?: string }>; @@ -235,12 +243,14 @@ export default function LocationsPage() { const router = useRouter(); const [loading, setLoading] = useState(true); const [locations, setLocations] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + LOCATIONS_LIST_FILTERS_KEY, + LOCATIONS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -281,11 +291,11 @@ export default function LocationsPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); useEffect(() => { const fetchCountryCodes = async () => { @@ -305,21 +315,23 @@ export default function LocationsPage() { }, [t]); useEffect(() => { + if (!hydrated) { + return; + } + const fetchLocations = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/locations', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, }); setLocations(data.data.locations || []); const p = data.data.pagination; setPagination({ - page: p.current_page, - pageSize: p.per_page, total: p.total_records, totalPages: Math.ceil(p.total_records / p.per_page), hasNext: p.has_next, @@ -332,7 +344,7 @@ export default function LocationsPage() { } }; fetchLocations(); - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, refreshKey, t]); + }, [page, pageSize, debouncedSearchQuery, refreshKey, t, hydrated]); const handleEdit = async (location: Location) => { try { @@ -450,7 +462,7 @@ export default function LocationsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    @@ -463,21 +475,21 @@ export default function LocationsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/mail-templates/page.tsx b/frontendv2/src/app/(app)/admin/mail-templates/page.tsx index b6c110c70..20bbdc487 100644 --- a/frontendv2/src/app/(app)/admin/mail-templates/page.tsx +++ b/frontendv2/src/app/(app)/admin/mail-templates/page.tsx @@ -46,6 +46,7 @@ import { import axios, { isAxiosError } from 'axios'; import { toast } from 'sonner'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface MailTemplate { @@ -67,20 +68,29 @@ interface Pagination { to: number; } +const MAIL_TEMPLATES_LIST_FILTERS_KEY = 'featherpanel_admin_mail_templates_filters_v1'; +const MAIL_TEMPLATES_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + export default function MailTemplatesPage() { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const { filters, patchFilters, hydrated } = usePersistedListFilters( + MAIL_TEMPLATES_LIST_FILTERS_KEY, + MAIL_TEMPLATES_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const [pagination, setPagination] = useState>({ total: 0, hasNext: false, hasPrev: false, from: 0, to: 0, }); - const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [createOpen, setCreateOpen] = useState(false); @@ -103,19 +113,25 @@ export default function MailTemplatesPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); + + const totalPages = Math.ceil(pagination.total / pageSize) || 1; const fetchTemplates = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get('/api/admin/mail-templates', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, }); @@ -123,8 +139,6 @@ export default function MailTemplatesPage() { setTemplates(data.data.templates || []); const apiPag = data.data.pagination; setPagination({ - page: apiPag.current_page, - pageSize: apiPag.per_page, total: apiPag.total_records, hasNext: apiPag.has_next, hasPrev: apiPag.has_prev, @@ -140,7 +154,7 @@ export default function MailTemplatesPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, t]); + }, [page, pageSize, debouncedSearchQuery, t, hydrated]); useEffect(() => { fetchTemplates(); @@ -298,33 +312,33 @@ export default function MailTemplatesPage() { className='h-11 w-full pl-10' placeholder={t('admin.mail_templates.search_placeholder')} value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} />
    - {pagination.total > pagination.pageSize && !loading && ( + {pagination.total > pageSize && !loading && (
    - {pagination.page} / {Math.ceil(pagination.total / pagination.pageSize)} + {page} / {totalPages}
    - {pagination.total > pagination.pageSize && ( + {pagination.total > pageSize && (
    - {pagination.page} / {Math.ceil(pagination.total / pagination.pageSize)} + {page} / {totalPages} diff --git a/frontendv2/src/app/(app)/admin/mounts/page.tsx b/frontendv2/src/app/(app)/admin/mounts/page.tsx index fab2a8046..11dc79243 100644 --- a/frontendv2/src/app/(app)/admin/mounts/page.tsx +++ b/frontendv2/src/app/(app)/admin/mounts/page.tsx @@ -32,6 +32,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { toast } from 'sonner'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { HardDrive, Plus, Search, Pencil, Trash2, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; @@ -72,6 +73,13 @@ interface Pagination { const MAX_ADMIN_LIST_PAGES = 500; +const MOUNTS_LIST_FILTERS_KEY = 'featherpanel_admin_mounts_filters_v1'; +const MOUNTS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 20, +}; + async function fetchAllNodes(): Promise { const out: PickNode[] = []; let page = 1; @@ -110,11 +118,13 @@ export default function AdminMountsPage() { const [loading, setLoading] = useState(true); const [mounts, setMounts] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + MOUNTS_LIST_FILTERS_KEY, + MOUNTS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; const [debouncedSearch, setDebouncedSearch] = useState(''); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 20, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -166,23 +176,25 @@ export default function AdminMountsPage() { useEffect(() => { const tmr = setTimeout(() => { - setDebouncedSearch((prev) => { - if (searchQuery !== prev) { - setPagination((p) => ({ ...p, page: 1 })); - } - return searchQuery; - }); + setDebouncedSearch(searchQuery); + if (searchQuery !== debouncedSearch) { + patchFilters({ page: 1 }); + } }, 400); return () => clearTimeout(tmr); - }, [searchQuery]); + }, [searchQuery, debouncedSearch, patchFilters]); const loadMounts = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get('/api/admin/mounts', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearch || undefined, sort_by: 'name', sort_order: 'ASC', @@ -196,13 +208,12 @@ export default function AdminMountsPage() { setMounts(rows); const p = data.data?.pagination; if (p) { - setPagination((prev) => ({ - ...prev, + setPagination({ total: p.total_records, totalPages: p.total_pages, hasNext: p.has_next, hasPrev: p.has_prev, - })); + }); } } catch (e) { console.error(e); @@ -210,7 +221,7 @@ export default function AdminMountsPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearch, t]); + }, [page, pageSize, debouncedSearch, t, hydrated]); useEffect(() => { loadMounts(); @@ -421,7 +432,7 @@ export default function AdminMountsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    @@ -432,20 +443,20 @@ export default function AdminMountsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/notifications/page.tsx b/frontendv2/src/app/(app)/admin/notifications/page.tsx index 00cb57975..dae34677d 100644 --- a/frontendv2/src/app/(app)/admin/notifications/page.tsx +++ b/frontendv2/src/app/(app)/admin/notifications/page.tsx @@ -49,6 +49,7 @@ import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface Notification { @@ -72,16 +73,25 @@ interface Pagination { hasPrev: boolean; } +const NOTIFICATIONS_LIST_FILTERS_KEY = 'featherpanel_admin_notifications_filters_v1'; +const NOTIFICATIONS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + export default function NotificationsPage() { const { t } = useTranslation(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-notifications'); const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + NOTIFICATIONS_LIST_FILTERS_KEY, + NOTIFICATIONS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -112,22 +122,26 @@ export default function NotificationsPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((prev) => ({ ...prev, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [debouncedSearchQuery, searchQuery]); + }, [debouncedSearchQuery, patchFilters, searchQuery]); useEffect(() => { + if (!hydrated) { + return; + } + const controller = new AbortController(); const fetchNotifications = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/notifications', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, signal: controller.signal, @@ -135,15 +149,12 @@ export default function NotificationsPage() { setNotifications(data.data.notifications || []); const apiPagination = data.data.pagination; - setPagination((prev) => ({ - ...prev, - page: apiPagination.current_page, - pageSize: apiPagination.per_page, + setPagination({ total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, hasPrev: apiPagination.has_prev, - })); + }); } catch (error) { if (!axios.isCancel(error)) { console.error('Error fetching notifications:', error); @@ -161,7 +172,7 @@ export default function NotificationsPage() { return () => { controller.abort(); }; - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets]); + }, [page, pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets, hydrated]); const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); @@ -317,7 +328,7 @@ export default function NotificationsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' /> @@ -328,21 +339,21 @@ export default function NotificationsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages}
    - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages}
    diff --git a/frontendv2/src/app/(app)/admin/plugins/page.tsx b/frontendv2/src/app/(app)/admin/plugins/page.tsx index e44b38227..7bf43bd77 100644 --- a/frontendv2/src/app/(app)/admin/plugins/page.tsx +++ b/frontendv2/src/app/(app)/admin/plugins/page.tsx @@ -18,6 +18,7 @@ See the LICENSE file or . import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from '@/contexts/TranslationContext'; import axios from 'axios'; +import { invalidatePluginRoutesCache } from '@/hooks/usePluginRoutes'; import { PageHeader } from '@/components/featherui/PageHeader'; import { PageCard } from '@/components/featherui/PageCard'; import { Button } from '@/components/featherui/Button'; @@ -407,6 +408,7 @@ export default function PluginsPage() { await axios.post(`/api/admin/plugins/${selectedPlugin.identifier}/spell-restrictions`, { allowedOnlyOnSpells: Array.from(selectedSpellIds), }); + invalidatePluginRoutesCache(); toast.success(t('admin.plugins.messages.spell_restrictions_saved')); await loadPluginConfig(selectedPlugin); diff --git a/frontendv2/src/app/(app)/admin/realms/page.tsx b/frontendv2/src/app/(app)/admin/realms/page.tsx index 7b57e59b2..726154740 100644 --- a/frontendv2/src/app/(app)/admin/realms/page.tsx +++ b/frontendv2/src/app/(app)/admin/realms/page.tsx @@ -32,6 +32,7 @@ import { Label } from '@/components/ui/label'; import { toast } from 'sonner'; import { Sparkles, Plus, Search, Pencil, Trash2, ChevronLeft, ChevronRight, FolderTree } from 'lucide-react'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface Realm { @@ -51,18 +52,27 @@ interface Pagination { hasPrev: boolean; } +const REALMS_LIST_FILTERS_KEY = 'featherpanel_admin_realms_filters_v1'; +const REALMS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + export default function RealmsPage() { const { t } = useTranslation(); const router = useRouter(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-realms'); const [loading, setLoading] = useState(true); const [realms, setRealms] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + REALMS_LIST_FILTERS_KEY, + REALMS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -82,20 +92,24 @@ export default function RealmsPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); useEffect(() => { + if (!hydrated) { + return; + } + const fetchRealms = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/realms', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, }); @@ -103,8 +117,6 @@ export default function RealmsPage() { setRealms(data.data.realms || []); const apiPagination = data.data.pagination; setPagination({ - page: apiPagination.current_page, - pageSize: apiPagination.per_page, total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, @@ -120,7 +132,7 @@ export default function RealmsPage() { fetchRealms(); fetchWidgets(); - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets]); + }, [page, pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets, hydrated]); const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); @@ -207,7 +219,7 @@ export default function RealmsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' /> @@ -218,21 +230,21 @@ export default function RealmsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/roles/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/roles/[id]/edit/page.tsx new file mode 100644 index 000000000..04fbe35bf --- /dev/null +++ b/frontendv2/src/app/(app)/admin/roles/[id]/edit/page.tsx @@ -0,0 +1,76 @@ +/* +This file is part of FeatherPanel. + +Copyright (C) 2025 MythicalSystems Studios +Copyright (C) 2025 FeatherPanel Contributors +Copyright (C) 2025 Cassian Gherman (aka NaysKutzu) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +See the LICENSE file or . +*/ + +'use client'; + +import { useEffect } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { useTranslation } from '@/contexts/TranslationContext'; +import { RoleEditor } from '@/components/admin/RoleEditor'; +import { useRoleEditor } from '@/hooks/useRoleEditor'; +import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { WidgetRenderer } from '@/components/server/WidgetRenderer'; + +export default function EditRolePage() { + const { t } = useTranslation(); + const params = useParams(); + const searchParams = useSearchParams(); + const roleId = Number(params.id); + const initialTab = searchParams.get('tab') === 'permissions' ? 'permissions' : 'details'; + + const { fetchWidgets, getWidgets } = usePluginWidgets('admin-roles-edit'); + + useEffect(() => { + fetchWidgets(); + }, [fetchWidgets]); + + const editor = useRoleEditor({ + mode: 'edit', + roleId: Number.isFinite(roleId) ? roleId : undefined, + initialTab, + }); + + return ( + <> + + { + editor.nameManuallyEdited.current = true; + }} + activeTab={editor.activeTab} + onTabChange={editor.setActiveTab} + roleId={editor.editorRoleId} + isYourRole={editor.isYourRole} + isSubmitting={editor.isSubmitting} + onSave={editor.handleSaveRole} + onDelete={editor.handleDeleteRole} + loadingPermissions={editor.loadingPermissions} + assignedPermissionMap={editor.assignedPermissionMap} + onTogglePermission={editor.togglePermission} + onBulkTogglePermissions={editor.bulkTogglePermissions} + togglingPermission={editor.togglingPermission} + isRootLocked={editor.isRootLocked} + canEditPermissions={editor.canEditPermissions} + t={t} + /> + + + ); +} diff --git a/frontendv2/src/app/(app)/admin/roles/create/page.tsx b/frontendv2/src/app/(app)/admin/roles/create/page.tsx new file mode 100644 index 000000000..9b18637d2 --- /dev/null +++ b/frontendv2/src/app/(app)/admin/roles/create/page.tsx @@ -0,0 +1,90 @@ +/* +This file is part of FeatherPanel. + +Copyright (C) 2025 MythicalSystems Studios +Copyright (C) 2025 FeatherPanel Contributors +Copyright (C) 2025 Cassian Gherman (aka NaysKutzu) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +See the LICENSE file or . +*/ + +'use client'; + +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useTranslation } from '@/contexts/TranslationContext'; +import { RoleEditor } from '@/components/admin/RoleEditor'; +import { useRoleEditor } from '@/hooks/useRoleEditor'; +import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { WidgetRenderer } from '@/components/server/WidgetRenderer'; +import { TableSkeleton } from '@/components/featherui/TableSkeleton'; + +export default function CreateRolePage() { + const { t } = useTranslation(); + const { fetchWidgets, getWidgets } = usePluginWidgets('admin-roles-create'); + const [roleCount, setRoleCount] = useState(0); + const [countLoaded, setCountLoaded] = useState(false); + + useEffect(() => { + fetchWidgets(); + }, [fetchWidgets]); + + useEffect(() => { + const fetchCount = async () => { + try { + const { data } = await axios.get('/api/admin/roles', { params: { limit: 1, page: 1 } }); + setRoleCount(data.data.pagination?.total_records ?? 0); + } catch { + setRoleCount(0); + } finally { + setCountLoaded(true); + } + }; + fetchCount(); + }, []); + + const editor = useRoleEditor({ + mode: 'create', + defaultRoleCount: roleCount, + }); + + if (!countLoaded) { + return ; + } + + return ( + <> + + { + editor.nameManuallyEdited.current = true; + }} + activeTab={editor.activeTab} + onTabChange={editor.setActiveTab} + roleId={editor.editorRoleId} + isYourRole={editor.isYourRole} + isSubmitting={editor.isSubmitting} + onSave={editor.handleSaveRole} + loadingPermissions={editor.loadingPermissions} + assignedPermissionMap={editor.assignedPermissionMap} + onTogglePermission={editor.togglePermission} + onBulkTogglePermissions={editor.bulkTogglePermissions} + togglingPermission={editor.togglingPermission} + isRootLocked={editor.isRootLocked} + canEditPermissions={editor.canEditPermissions} + t={t} + /> + + + ); +} diff --git a/frontendv2/src/app/(app)/admin/roles/page.tsx b/frontendv2/src/app/(app)/admin/roles/page.tsx index 331115714..97a9581c7 100644 --- a/frontendv2/src/app/(app)/admin/roles/page.tsx +++ b/frontendv2/src/app/(app)/admin/roles/page.tsx @@ -15,51 +15,38 @@ See the LICENSE file or . 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import axios, { isAxiosError } from 'axios'; import { useTranslation } from '@/contexts/TranslationContext'; +import { useSession } from '@/contexts/SessionContext'; import { Shield, Plus, - Pencil, Trash2, Search, - RefreshCw, - X, ChevronLeft, ChevronRight, KeyRound, AlertCircle, + Copy, + Users, + Pencil, } from 'lucide-react'; import { PageHeader } from '@/components/featherui/PageHeader'; -import { ResourceCard, type ResourceBadge } from '@/components/featherui/ResourceCard'; import { EmptyState } from '@/components/featherui/EmptyState'; import { TableSkeleton } from '@/components/featherui/TableSkeleton'; import { Button } from '@/components/featherui/Button'; import { Input } from '@/components/featherui/Input'; import { PageCard } from '@/components/featherui/PageCard'; -import { Sheet, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet'; import { toast } from 'sonner'; -import { Label } from '@/components/ui/label'; -import Permissions from '@/lib/permissions'; import { Badge } from '@/components/ui/badge'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; - -interface Role { - id: number; - name: string; - display_name: string; - color: string; - created_at: string; - updated_at: string; -} - -interface Permission { - id: number; - role_id: number; - permission: string; -} +import { cn } from '@/lib/utils'; +import { RoleBadge } from '@/components/RoleBadge'; +import { isDefaultRole, type Role } from '@/lib/role-utils'; interface Pagination { page: number; @@ -70,82 +57,98 @@ interface Pagination { hasPrev: boolean; } +const ROLES_LIST_FILTERS_KEY = 'featherpanel_admin_roles_filters_v1'; +const ROLES_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 12, +}; + export default function RolesPage() { const { t } = useTranslation(); + const { user } = useSession(); + const router = useRouter(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-roles'); const [roles, setRoles] = useState([]); + const [permissionCounts, setPermissionCounts] = useState>({}); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + ROLES_LIST_FILTERS_KEY, + ROLES_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const [isSubmitting, setIsSubmitting] = useState(false); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, hasPrev: false, }); - const [createOpen, setCreateOpen] = useState(false); - const [editOpen, setEditOpen] = useState(false); - const [permissionsOpen, setPermissionsOpen] = useState(false); - - const [editingRole, setEditingRole] = useState(null); - const [permissionsRole, setPermissionsRole] = useState(null); - const [rolePermissions, setRolePermissions] = useState([]); - const [loadingPermissions, setLoadingPermissions] = useState(false); - - const [newRole, setNewRole] = useState({ - name: '', - display_name: '', - color: '#5B8DEF', - }); - const [roleColorHex, setRoleColorHex] = useState('#5B8DEF'); - - const [isSubmitting, setIsSubmitting] = useState(false); - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [refreshKey, setRefreshKey] = useState(0); - const [permissionSearch, setPermissionSearch] = useState(''); - const allPermissions = useMemo(() => Permissions.getAll(), []); - useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((prev) => ({ ...prev, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [debouncedSearchQuery, searchQuery]); + }, [debouncedSearchQuery, patchFilters, searchQuery]); useEffect(() => { + if (!hydrated) { + return; + } + const controller = new AbortController(); const fetchRoles = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/roles', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, }, signal: controller.signal, }); - setRoles(data.data.roles || []); + const fetchedRoles: Role[] = data.data.roles || []; + setRoles(fetchedRoles); const apiPagination = data.data.pagination; - setPagination((prev) => ({ - ...prev, - page: apiPagination.current_page, - pageSize: apiPagination.per_page, + setPagination({ total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, hasPrev: apiPagination.has_prev, - })); + }); + + if (fetchedRoles.length > 0) { + const counts = await Promise.all( + fetchedRoles.map(async (role) => { + try { + const permRes = await axios.get('/api/admin/permissions', { + params: { role_id: role.id, limit: 1 }, + signal: controller.signal, + }); + return { + id: role.id, + count: permRes.data.data.pagination?.total_records ?? 0, + }; + } catch { + return { id: role.id, count: 0 }; + } + }), + ); + setPermissionCounts(Object.fromEntries(counts.map((entry) => [entry.id, entry.count]))); + } else { + setPermissionCounts({}); + } } catch (error) { if (!axios.isCancel(error)) { console.error('Error fetching roles:', error); @@ -163,57 +166,7 @@ export default function RolesPage() { return () => { controller.abort(); }; - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets]); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - try { - await axios.put('/api/admin/roles', newRole); - toast.success(t('admin.roles.messages.created')); - setCreateOpen(false); - resetNewRole(); - setRefreshKey((prev) => prev + 1); - } catch (error: unknown) { - console.error('Error creating role:', error); - let errorMessage = t('admin.roles.messages.create_failed'); - if (isAxiosError(error) && error.response?.data?.message) { - errorMessage = error.response.data.message; - } - toast.error(errorMessage); - } finally { - setIsSubmitting(false); - } - }; - - const handleUpdate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!editingRole) return; - - setIsSubmitting(true); - try { - const payload = { - name: editingRole.name, - display_name: editingRole.display_name, - color: editingRole.color, - }; - - await axios.patch(`/api/admin/roles/${editingRole.id}`, payload); - toast.success(t('admin.roles.messages.updated')); - setEditOpen(false); - setEditingRole(null); - setRefreshKey((prev) => prev + 1); - } catch (error: unknown) { - console.error('Error updating role:', error); - let errorMessage = t('admin.roles.messages.update_failed'); - if (isAxiosError(error) && error.response?.data?.message) { - errorMessage = error.response.data.message; - } - toast.error(errorMessage); - } finally { - setIsSubmitting(false); - } - }; + }, [page, pageSize, debouncedSearchQuery, refreshKey, t, fetchWidgets, hydrated]); const handleDelete = async (id: number) => { if (!confirm(t('admin.roles.delete_confirm'))) return; @@ -235,93 +188,45 @@ export default function RolesPage() { } }; - const fetchPermissions = async (roleId: number) => { - setLoadingPermissions(true); + const handleDuplicate = async (role: Role) => { + setIsSubmitting(true); try { - const { data } = await axios.get('/api/admin/permissions', { - // Fetch the full role permission set for the sidebar list. - params: { role_id: roleId, limit: 100 }, + const { data: createData } = await axios.put('/api/admin/roles', { + name: `${role.name}_copy`, + display_name: `${role.display_name} (Copy)`, + custom_badge: role.custom_badge ?? '', + color: role.color, }); - setRolePermissions(data.data.permissions || []); - } catch (error) { - console.error('Error fetching permissions:', error); - } finally { - setLoadingPermissions(false); - } - }; + const newRole = createData.data.role as Role; - const handleAddPermission = async (permissionValue: string) => { - if (!permissionsRole) return; - try { - const { data } = await axios.put('/api/admin/permissions', { - role_id: permissionsRole.id, - permission: permissionValue, + const { data: permData } = await axios.get('/api/admin/permissions', { + params: { role_id: role.id, limit: 500 }, }); - if (data.success) { - toast.success(t('admin.roles.messages.permission_added')); - await fetchPermissions(permissionsRole.id); - } + const sourcePermissions = permData.data.permissions || []; + + await Promise.all( + sourcePermissions.map((perm: { permission: string }) => + axios.put('/api/admin/permissions', { + role_id: newRole.id, + permission: perm.permission, + }), + ), + ); + + toast.success(t('admin.roles.messages.duplicated')); + router.push(`/admin/roles/${newRole.id}/edit`); } catch (error: unknown) { - let errorMessage = t('admin.roles.messages.permission_failed'); - if (isAxiosError(error) && error.response?.data?.message) { - errorMessage = error.response.data.message; - } - toast.error(errorMessage); - } - }; - - const handleDeletePermission = async (permissionId: number) => { - if (!permissionsRole) return; - try { - const { data } = await axios.delete(`/api/admin/permissions/${permissionId}`); - if (data.success) { - toast.success(t('admin.roles.messages.permission_removed')); - await fetchPermissions(permissionsRole.id); - } - } catch (error: unknown) { - let errorMessage = t('admin.roles.messages.permission_failed'); + console.error('Error duplicating role:', error); + let errorMessage = t('admin.roles.messages.duplicate_failed'); if (isAxiosError(error) && error.response?.data?.message) { errorMessage = error.response.data.message; } toast.error(errorMessage); + } finally { + setIsSubmitting(false); } }; - const resetNewRole = () => { - setNewRole({ - name: '', - display_name: '', - color: '#5B8DEF', - }); - setRoleColorHex('#5B8DEF'); - }; - - const openEdit = (role: Role) => { - setEditingRole({ ...role }); - setEditOpen(true); - }; - - const openPermissions = (role: Role) => { - setPermissionsRole(role); - fetchPermissions(role.id); - setPermissionsOpen(true); - setPermissionSearch(''); - }; - - const filteredAvailablePermissions = useMemo(() => { - const assigned = new Set(rolePermissions.map((p) => p.permission)); - const search = permissionSearch.toLowerCase(); - - return allPermissions.filter((p) => { - const isAssigned = assigned.has(p.value); - const matchesSearch = - p.value.toLowerCase().includes(search) || - p.description.toLowerCase().includes(search) || - p.category.toLowerCase().includes(search); - return !isAssigned && matchesSearch; - }); - }, [rolePermissions, allPermissions, permissionSearch]); - return (
    @@ -330,12 +235,7 @@ export default function RolesPage() { description={t('admin.roles.subtitle')} icon={Shield} actions={ - @@ -350,10 +250,16 @@ export default function RolesPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    + {!loading && ( +
    + + {t('admin.roles.stats.role_count', { count: String(pagination.total) })} +
    + )} {pagination.totalPages > 1 && !loading && ( @@ -361,21 +267,21 @@ export default function RolesPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} + } /> ) : ( -
    +
    {roles.map((role) => { - const badges: ResourceBadge[] = [ - { - label: role.name, - className: 'bg-secondary text-secondary-foreground font-mono', - }, - ]; + const permCount = permissionCounts[role.id]; + const isYours = user?.role_id === role.id; return ( - + className={cn( + 'group bg-card/50 border-border/70 relative flex flex-col overflow-hidden rounded-2xl border shadow-sm backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:shadow-md', + isYours && 'ring-primary/30 ring-2', + )} + style={{ borderColor: `${role.color}44` }} + > +
    + + - - + +
    +
    +
    +

    + {role.display_name} +

    + + {isYours && ( + + {t('admin.roles.labels.your_role')} + + )} +
    +

    + {role.name} +

    +
    - } - /> + +
    + + + {permCount !== undefined + ? t('admin.roles.stats.permission_count', { count: String(permCount) }) + : '...'} + + + {new Date(role.created_at).toLocaleDateString()} + +
    + + +
    + + + +
    +
    ); })} @@ -476,28 +403,26 @@ export default function RolesPage() { -
    - - {pagination.page} / {pagination.totalPages} - -
    + + {page} / {pagination.totalPages} + )} -
    +

    {t('admin.roles.help.managing.description')} @@ -517,240 +442,6 @@ export default function RolesPage() {

    - -
    - - {t('admin.roles.form.create_title')} - {t('admin.roles.create_description')} - - -
    -
    - - setNewRole({ ...newRole, name: e.target.value })} - required - placeholder='admin' - /> -
    - -
    - - setNewRole({ ...newRole, display_name: e.target.value })} - required - placeholder='Administrator' - /> -
    - -
    - -
    - { - setNewRole({ ...newRole, color: e.target.value }); - setRoleColorHex(e.target.value.toUpperCase()); - }} - className='h-10 w-12 cursor-pointer p-1' - /> - { - setRoleColorHex(e.target.value); - if (/^#[0-9A-F]{6}$/i.test(e.target.value)) { - setNewRole({ ...newRole, color: e.target.value }); - } - }} - required - placeholder='#5B8DEF' - className='flex-1' - /> -
    -
    - - - - -
    -
    -
    - - -
    - - {t('admin.roles.form.edit_title')} - {t('admin.roles.edit_description')} - - - {editingRole && ( -
    -
    - - setEditingRole({ ...editingRole, name: e.target.value })} - required - /> -
    - -
    - - setEditingRole({ ...editingRole, display_name: e.target.value })} - required - /> -
    - -
    - -
    - setEditingRole({ ...editingRole, color: e.target.value })} - className='h-10 w-12 cursor-pointer p-1' - /> - setEditingRole({ ...editingRole, color: e.target.value })} - required - className='flex-1' - /> -
    -
    - - - - -
    - )} -
    -
    - - -
    - - - {t('admin.roles.permissions.title')} - {permissionsRole && ( - - {permissionsRole.display_name} - - )} - - {t('admin.roles.permissions.description')} - - -
    -
    - - setPermissionSearch(e.target.value)} - className='bg-background/20 focus-visible:ring-primary/30 h-11 border-none pl-10 focus-visible:ring-1' - /> - - {permissionSearch && ( -
    - {filteredAvailablePermissions.length === 0 ? ( -
    - {t('admin.roles.no_results')} -
    - ) : ( -
    - {filteredAvailablePermissions.map((perm) => ( -
    { - handleAddPermission(perm.value); - setPermissionSearch(''); - }} - > -
    - - {perm.value} - - -
    - - {perm.description} - -
    - ))} -
    - )} -
    - )} -
    - -
    - {loadingPermissions ? ( -
    - - - {t('admin.roles.permissions.syncing')} - -
    - ) : rolePermissions.length === 0 ? ( -
    - {t('admin.roles.form.no_permissions')} -
    - ) : ( -
    - {rolePermissions.map((perm) => ( -
    -
    - - {perm.permission} - -
    - -
    - ))} -
    - )} -
    -
    - - - - -
    -
    ); diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ApplicationTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ApplicationTab.tsx index 2ebd15794..b6d90bd9f 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ApplicationTab.tsx +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ApplicationTab.tsx @@ -20,15 +20,12 @@ import { PageCard } from '@/components/featherui/PageCard'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/featherui/Button'; -import { HeadlessSelect } from '@/components/ui/headless-select'; import { Box, Wand2, Search } from 'lucide-react'; -import { TabProps, SelectedEntities, Spell, SpellVariable } from './types'; +import { TabProps, SelectedEntities, SpellVariable } from './types'; interface ApplicationTabProps extends TabProps { selectedEntities: SelectedEntities; - spellDetails: Spell | null; spellVariables: SpellVariable[]; - dockerImages: string[]; setRealmModalOpen: (open: boolean) => void; setSpellModalOpen: (open: boolean) => void; fetchRealms: () => void; @@ -41,7 +38,6 @@ export function ApplicationTab({ errors, selectedEntities, spellVariables, - dockerImages, setRealmModalOpen, setSpellModalOpen, fetchRealms, @@ -145,22 +141,6 @@ export function ApplicationTab({ {errors.spell_id &&

    {errors.spell_id}

    } - - {dockerImages.length > 0 && ( -
    - - setForm((prev) => ({ ...prev, image: String(val) }))} - options={dockerImages.map((img) => ({ id: img, name: img }))} - placeholder={t('admin.servers.form.select_docker_image')} - /> -

    {t('admin.servers.form.docker_image_help')}

    -
    - )} diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/DetailsTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/DetailsTab.tsx index fb8d951b7..4bfbfb5c0 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/DetailsTab.tsx +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/DetailsTab.tsx @@ -15,6 +15,7 @@ See the LICENSE file or . 'use client'; +import Link from 'next/link'; import { useTranslation } from '@/contexts/TranslationContext'; import { PageCard } from '@/components/featherui/PageCard'; import { Input } from '@/components/ui/input'; @@ -105,9 +106,20 @@ export function DetailsTab({ {selectedEntities.owner ? (
    - - {selectedEntities.owner.username} - + {selectedEntities.owner.uuid ? ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {selectedEntities.owner.username} + + ) : ( + + {selectedEntities.owner.username} + + )} ({selectedEntities.owner.email})
    ) : ( diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ResourcesTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ResourcesTab.tsx index 866db9fa9..758981d78 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ResourcesTab.tsx +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ResourcesTab.tsx @@ -20,6 +20,7 @@ import { PageCard } from '@/components/featherui/PageCard'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/featherui/Button'; +import { SERVER_RESOURCE_LIMITS } from '@/lib/server-utils'; import { TabProps } from './types'; export function ResourcesTab({ form, setForm, errors }: TabProps) { @@ -61,7 +62,7 @@ export function ResourcesTab({ form, setForm, errors }: TabProps) { type='number' value={form.memory} onChange={(e) => setForm((prev) => ({ ...prev, memory: Number(e.target.value) }))} - min={0} + min={SERVER_RESOURCE_LIMITS.memory.min} className={`bg-muted/30 h-11 ${errors.memory ? 'border-red-500' : ''}`} /> )} @@ -147,7 +148,7 @@ export function ResourcesTab({ form, setForm, errors }: TabProps) { type='number' value={form.disk} onChange={(e) => setForm((prev) => ({ ...prev, disk: Number(e.target.value) }))} - min={0} + min={SERVER_RESOURCE_LIMITS.disk.min} className={`bg-muted/30 h-11 ${errors.disk ? 'border-red-500' : ''}`} /> )} @@ -183,7 +184,7 @@ export function ResourcesTab({ form, setForm, errors }: TabProps) { type='number' value={form.cpu} onChange={(e) => setForm((prev) => ({ ...prev, cpu: Number(e.target.value) }))} - min={0} + min={SERVER_RESOURCE_LIMITS.cpu.min} className={`bg-muted/30 h-11 ${errors.cpu ? 'border-red-500' : ''}`} /> )} diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx index e07b1a96b..ea306c8e8 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx @@ -22,9 +22,12 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/featherui/Button'; import { Plus, Trash2, Lock } from 'lucide-react'; +import { DockerImageField, DockerImageOption } from '@/components/admin/DockerImageField'; import { CustomVariable, TabProps } from './types'; interface StartupTabProps extends TabProps { + dockerImages: DockerImageOption[]; + spellDefaultDockerImage: string; customVariables: CustomVariable[]; customVariableForm: { name: string; @@ -49,6 +52,8 @@ export function StartupTab({ form, setForm, errors, + dockerImages, + spellDefaultDockerImage, customVariables, customVariableForm, customVariableSaving, @@ -59,42 +64,56 @@ export function StartupTab({ const { t } = useTranslation(); return ( - -
    - - setForm((prev) => ({ ...prev, startup: e.target.value }))} - placeholder={t('admin.servers.form.startup_placeholder')} - className={`bg-muted/30 h-11 font-mono ${errors.startup ? 'border-red-500' : ''}`} - /> - {errors.startup &&

    {errors.startup}

    } -

    {t('admin.servers.form.startup_help')}

    +
    + +
    + + setForm((prev) => ({ ...prev, startup: e.target.value }))} + placeholder={t('admin.servers.form.startup_placeholder')} + className={`bg-muted/30 h-11 font-mono ${errors.startup ? 'border-red-500' : ''}`} + /> + {errors.startup &&

    {errors.startup}

    } +

    {t('admin.servers.form.startup_help')}

    -
    -

    {t('admin.servers.edit.startup.available_variables')}

    -
    - {'{{SERVER_MEMORY}}'} - {'{{SERVER_IP}}'} - {'{{SERVER_PORT}}'} +
    +

    + {t('admin.servers.edit.startup.available_variables')} +

    +
    + {'{{SERVER_MEMORY}}'} + {'{{SERVER_IP}}'} + {'{{SERVER_PORT}}'} +
    + -
    -
    -

    Custom environment variables

    -

    - These are synced to Wings without a server transfer. Encrypted values are hidden after - creation. -

    -
    + + setForm((prev) => ({ ...prev, image }))} + images={dockerImages} + defaultImage={spellDefaultDockerImage} + error={errors.image} + /> + + +
    {customVariables.length > 0 && (
    {customVariables.map((variable) => ( @@ -188,7 +207,7 @@ export function StartupTab({ Encrypt this value and hide it after save
    -
    -
    + +
    ); } diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx index dc8a1cd96..b3a30852e 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx @@ -42,10 +42,11 @@ import { } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Sheet, SheetHeader, SheetTitle, SheetDescription, SheetContent } from '@/components/ui/sheet'; -import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { toast } from 'sonner'; import { formatDateTimeInTz, formatRelativeTime } from '@/lib/dateUtils'; +import { RoleBadge } from '@/components/RoleBadge'; +import { validateServerResourceLimits } from '@/lib/server-utils'; import { DetailsTab } from './DetailsTab'; import { ResourcesTab } from './ResourcesTab'; @@ -57,6 +58,8 @@ import { MountsTab } from './MountsTab'; import type { AssignableMountRow } from './MountsTab'; import { ActionsTab } from './ActionsTab'; import { AllocationPickerSheet } from '@/components/admin/AllocationPickerSheet'; +import { resolveSpellDefaultDockerImage, buildSpellDockerImageOptions } from '@/lib/spellDockerImages'; +import type { DockerImageOption } from '@/components/admin/DockerImageField'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; @@ -145,7 +148,7 @@ export default function EditServerPage() { const [location, setLocation] = useState(null); const [node, setNode] = useState(null); - const [spellDetails, setSpellDetails] = useState(null); + const [, setSpellDetails] = useState(null); const [spellVariables, setSpellVariables] = useState([]); const [customVariables, setCustomVariables] = useState([]); const [customVariableSaving, setCustomVariableSaving] = useState(false); @@ -155,7 +158,8 @@ export default function EditServerPage() { variable_value: '', is_encrypted: false, }); - const [dockerImages, setDockerImages] = useState([]); + const [dockerImages, setDockerImages] = useState([]); + const [spellDefaultDockerImage, setSpellDefaultDockerImage] = useState(''); const [ownerModalOpen, setOwnerModalOpen] = useState(false); const [realmModalOpen, setRealmModalOpen] = useState(false); @@ -446,12 +450,9 @@ export default function EditServerPage() { if (serverSpell) { setSpellDetails(serverSpell); - try { - const images = JSON.parse(serverSpell.docker_images); - setDockerImages(Object.values(images)); - } catch { - setDockerImages([]); - } + const imageOptions = buildSpellDockerImageOptions(serverSpell, server.image || ''); + setDockerImages(imageOptions); + setSpellDefaultDockerImage(resolveSpellDefaultDockerImage(serverSpell)); } } } catch (error) { @@ -600,20 +601,18 @@ export default function EditServerPage() { const spell = spellRes.data.data.spell; setSpellDetails(spell); - try { - const images = JSON.parse(spell.docker_images); - const imageList = Object.values(images) as string[]; - setDockerImages(imageList); + const imageOptions = buildSpellDockerImageOptions(spell, form.image); + setDockerImages(imageOptions); + setSpellDefaultDockerImage(resolveSpellDefaultDockerImage(spell)); - setForm((prev) => { - if (!imageList.includes(prev.image)) { - return { ...prev, image: imageList[0] || '' }; - } + setForm((prev) => { + const allowedValues = imageOptions.map((img) => img.value); + if (prev.image && allowedValues.includes(prev.image)) { return prev; - }); - } catch { - setDockerImages([]); - } + } + const defaultImage = resolveSpellDefaultDockerImage(spell); + return { ...prev, image: defaultImage || allowedValues[0] || '' }; + }); if (variablesRes.data.success) { const newVariables = variablesRes.data.data.variables; @@ -641,6 +640,7 @@ export default function EditServerPage() { }; fetchSpellDetails(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [form.spell_id]); const fetchOwners = useCallback(async () => { @@ -900,6 +900,27 @@ export default function EditServerPage() { if (!form.realms_id) newErrors.realms_id = t('admin.servers.form.wizard.validation.realm_required'); if (!form.spell_id) newErrors.spell_id = t('admin.servers.form.wizard.validation.spell_required'); if (!form.startup) newErrors.startup = t('admin.servers.form.wizard.validation.startup_required'); + if (!form.image?.trim()) newErrors.image = t('admin.servers.form.wizard.validation.docker_image_required'); + + Object.assign( + newErrors, + validateServerResourceLimits( + { + memory: form.memory, + swap: form.swap, + disk: form.disk, + cpu: form.cpu, + io: form.io, + }, + { + memory: t('admin.servers.form.wizard.validation.memory_limit'), + swap: t('admin.servers.form.wizard.validation.swap_limit'), + disk: t('admin.servers.form.wizard.validation.disk_limit'), + cpu: t('admin.servers.form.wizard.validation.cpu_limit'), + io: t('admin.servers.form.wizard.validation.io_limit'), + }, + ), + ); spellVariables.forEach((variable) => { const value = form.variables[variable.env_variable]; @@ -1101,9 +1122,7 @@ export default function EditServerPage() { setForm={setForm} errors={errors} selectedEntities={selectedEntities} - spellDetails={spellDetails} spellVariables={spellVariables} - dockerImages={dockerImages} setRealmModalOpen={setRealmModalOpen} setSpellModalOpen={setSpellModalOpen} fetchRealms={fetchRealms} @@ -1120,6 +1139,8 @@ export default function EditServerPage() { form={form} setForm={setForm} errors={errors} + dockerImages={dockerImages} + spellDefaultDockerImage={spellDefaultDockerImage} customVariables={customVariables} customVariableForm={customVariableForm} customVariableSaving={customVariableSaving} @@ -1244,18 +1265,7 @@ export default function EditServerPage() {
    {user.username} - {user.role && ( - - {user.role.display_name} - - )} + {user.role && }
    {user.email} diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts b/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts index e6f5ed174..32288e409 100644 --- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts +++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts @@ -93,6 +93,7 @@ export interface Spell { description?: string; startup: string; docker_images: string; // JSON string + default_docker_image?: string | null; realms_id: number; } diff --git a/frontendv2/src/app/(app)/admin/servers/create/Step3Application.tsx b/frontendv2/src/app/(app)/admin/servers/create/Step3Application.tsx index 954d0ad81..441ca5553 100644 --- a/frontendv2/src/app/(app)/admin/servers/create/Step3Application.tsx +++ b/frontendv2/src/app/(app)/admin/servers/create/Step3Application.tsx @@ -20,8 +20,10 @@ import { PageCard } from '@/components/featherui/PageCard'; import { Button } from '@/components/featherui/Button'; import { Input } from '@/components/featherui/Input'; import { Label } from '@/components/ui/label'; -import { Sparkles, Search, Wand2, Box, Binary, Container } from 'lucide-react'; +import { Sparkles, Search, Wand2, Box, Binary } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { parseSpellDockerImages, resolveSpellDefaultDockerImage } from '@/lib/spellDockerImages'; +import { DockerImageField } from '@/components/admin/DockerImageField'; import { StepProps, Realm, Spell } from './types'; interface Step3Props extends StepProps { @@ -49,15 +51,11 @@ export function Step3Application({ const { t } = useTranslation(); const getDockerImages = (): { name: string; value: string }[] => { - if (!spellDetails?.docker_images) return []; - try { - const dockerImagesObj = JSON.parse(spellDetails.docker_images) as Record; - return Object.entries(dockerImagesObj).map(([name, value]) => ({ name, value })); - } catch { - return []; - } + return parseSpellDockerImages(spellDetails?.docker_images); }; + const defaultDockerImage = spellDetails ? resolveSpellDefaultDockerImage(spellDetails) : ''; + const dockerImages = getDockerImages(); const openRealmModal = () => { @@ -158,79 +156,12 @@ export function Step3Application({
    {formData.spellId && ( -
    -
    - - setFormData((prev) => ({ ...prev, dockerImage: e.target.value }))} - placeholder='ghcr.io/pterodactyl/yolks:java_8' - className='bg-muted/30 h-11 font-mono text-sm' - /> -

    - {t('admin.servers.form.docker_image_help')} -

    -
    - - {dockerImages.length > 0 && ( -
    - -
    - {dockerImages.map((img) => ( -
    - setFormData((prev) => ({ ...prev, dockerImage: img.value })) - } - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setFormData((prev) => ({ ...prev, dockerImage: img.value })); - } - }} - className={cn( - 'group/img relative cursor-pointer overflow-hidden rounded-xl border p-3 transition-all duration-200', - formData.dockerImage === img.value - ? 'bg-primary/10 border-primary/40 ring-primary/20 ring-1' - : 'bg-muted/20 border-border/50 hover:border-primary/30 hover:bg-muted/30', - )} - > -
    -
    - -
    -

    - {img.name} -

    -

    - {img.value} -

    -
    -
    - {formData.dockerImage === img.value && ( -
    - )} -
    -
    - ))} -
    -
    - )} -
    + setFormData((prev) => ({ ...prev, dockerImage }))} + images={dockerImages} + defaultImage={defaultDockerImage} + /> )}
    diff --git a/frontendv2/src/app/(app)/admin/servers/create/Step4Resources.tsx b/frontendv2/src/app/(app)/admin/servers/create/Step4Resources.tsx index 616358e5a..4a7a372f9 100644 --- a/frontendv2/src/app/(app)/admin/servers/create/Step4Resources.tsx +++ b/frontendv2/src/app/(app)/admin/servers/create/Step4Resources.tsx @@ -23,6 +23,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Cpu, MemoryStick, HardDrive } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { SERVER_RESOURCE_LIMITS } from '@/lib/server-utils'; import { StepProps } from './types'; export function Step4Resources({ formData, setFormData }: StepProps) { @@ -79,7 +80,7 @@ export function Step4Resources({ formData, setFormData }: StepProps) { setFormData((prev) => ({ ...prev, memory: parseInt(e.target.value) || 0 })) } placeholder='1024' - min={0} + min={SERVER_RESOURCE_LIMITS.memory.min} className='bg-muted/30' /> )} @@ -186,7 +187,7 @@ export function Step4Resources({ formData, setFormData }: StepProps) { setFormData((prev) => ({ ...prev, disk: parseInt(e.target.value) || 0 })) } placeholder='5120' - min={0} + min={SERVER_RESOURCE_LIMITS.disk.min} className='bg-muted/30' /> )} @@ -234,7 +235,7 @@ export function Step4Resources({ formData, setFormData }: StepProps) { setFormData((prev) => ({ ...prev, cpu: parseInt(e.target.value) || 0 })) } placeholder='100' - min={0} + min={SERVER_RESOURCE_LIMITS.cpu.min} className='bg-muted/30' /> )} diff --git a/frontendv2/src/app/(app)/admin/servers/create/page.tsx b/frontendv2/src/app/(app)/admin/servers/create/page.tsx index b471d2b12..6c72d2df5 100644 --- a/frontendv2/src/app/(app)/admin/servers/create/page.tsx +++ b/frontendv2/src/app/(app)/admin/servers/create/page.tsx @@ -50,6 +50,8 @@ import { Realm, WizardStep, } from './types'; +import { resolveSpellDefaultDockerImage } from '@/lib/spellDockerImages'; +import { validateServerResourceLimits } from '@/lib/server-utils'; import { Step1CoreDetails } from './Step1CoreDetails'; import { Step2Allocation } from './Step2Allocation'; import { Step3Application } from './Step3Application'; @@ -262,15 +264,10 @@ export default function CreateServerPage() { const spell: Spell = spellRes.data.data.spell; setSpellDetails(spell); - if (spell.docker_images) { - try { - const dockerImagesObj = JSON.parse(spell.docker_images); - const imagesArray = Object.values(dockerImagesObj) as string[]; - if (imagesArray.length > 0) { - setFormData((prev) => ({ ...prev, dockerImage: imagesArray[0] })); - } - } catch { - console.error('Failed to parse docker images'); + if (spell.docker_images || spell.default_docker_image) { + const defaultImage = resolveSpellDefaultDockerImage(spell); + if (defaultImage) { + setFormData((prev) => (prev.dockerImage ? prev : { ...prev, dockerImage: defaultImage })); } } @@ -515,6 +512,69 @@ export default function CreateServerPage() { } }, [spellModalOpen, fetchSpells]); + const validateFormForSubmit = () => { + if (!formData.name.trim()) { + toast.error(t('admin.servers.form.wizard.validation.name_required')); + return false; + } + if (!formData.ownerId) { + toast.error(t('admin.servers.form.wizard.validation.owner_required')); + return false; + } + if (!formData.locationId) { + toast.error(t('admin.servers.form.wizard.validation.location_required')); + return false; + } + if (!formData.nodeId) { + toast.error(t('admin.servers.form.wizard.validation.node_required')); + return false; + } + if (!formData.allocationId) { + toast.error(t('admin.servers.form.wizard.validation.allocation_required')); + return false; + } + if (!formData.realmId) { + toast.error(t('admin.servers.form.wizard.validation.realm_required')); + return false; + } + if (!formData.spellId) { + toast.error(t('admin.servers.form.wizard.validation.spell_required')); + return false; + } + if (!formData.dockerImage?.trim()) { + toast.error(t('admin.servers.form.wizard.validation.docker_image_required')); + return false; + } + if (!formData.startup?.trim()) { + toast.error(t('admin.servers.form.wizard.validation.startup_required')); + return false; + } + + const resourceErrors = validateServerResourceLimits( + { + memory: formData.memory, + swap: formData.swap, + disk: formData.disk, + cpu: formData.cpu, + io: formData.io, + }, + { + memory: t('admin.servers.form.wizard.validation.memory_limit'), + swap: t('admin.servers.form.wizard.validation.swap_limit'), + disk: t('admin.servers.form.wizard.validation.disk_limit'), + cpu: t('admin.servers.form.wizard.validation.cpu_limit'), + io: t('admin.servers.form.wizard.validation.io_limit'), + }, + ); + const firstResourceError = Object.values(resourceErrors)[0]; + if (firstResourceError) { + toast.error(firstResourceError); + return false; + } + + return true; + }; + const validateCurrentStep = () => { switch (currentStep) { case 1: @@ -583,7 +643,7 @@ export default function CreateServerPage() { } return; } - if (!validateCurrentStep()) return; + if (!validateFormForSubmit()) return; setSubmitting(true); try { @@ -730,7 +790,7 @@ export default function CreateServerPage() { > -
    @@ -579,7 +559,7 @@ export default function ServersPage() { variant='ghost' size='sm' className='h-9 text-xs' - onClick={() => setShowAdvancedFilters((prev) => !prev)} + onClick={() => patchFilters({ showAdvancedFilters: !showAdvancedFilters })} > {t('admin.servers.filters.advanced')} @@ -592,8 +572,7 @@ export default function ServersPage() { 'id' | 'name' | 'created_at' | 'updated_at', 'ASC' | 'DESC', ]; - setSortBy(field); - setSortOrder(order); + patchFilters({ sortBy: field, sortOrder: order, page: 1 }); }} className='bg-background/50 border-border/50 h-11 w-55 rounded-xl text-sm' > @@ -613,8 +592,7 @@ export default function ServersPage() { min={1} value={serverIdFilter} onChange={(e) => { - setServerIdFilter(e.target.value); - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ serverIdFilter: e.target.value, page: 1 }); }} placeholder={t('admin.servers.filters.server_id')} className='h-9 text-xs' @@ -622,8 +600,7 @@ export default function ServersPage() { { - setUuidFilter(e.target.value); - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ uuidFilter: e.target.value, page: 1 }); }} placeholder={t('admin.servers.filters.uuid')} className='h-9 text-xs' @@ -631,8 +608,7 @@ export default function ServersPage() { { - setExternalIdFilter(e.target.value); - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ externalIdFilter: e.target.value, page: 1 }); }} placeholder={t('admin.servers.filters.external_id')} className='h-9 text-xs' @@ -642,20 +618,7 @@ export default function ServersPage() { size='sm' className='h-9 justify-start text-xs' onClick={() => { - setOwnerFilter(''); - setNodeFilter(''); - setFilterOwner(null); - setFilterNode(null); - setRealmFilter(''); - setSpellFilter(''); - setLocationFilter(''); - setFilterRealm(null); - setFilterSpell(null); - setFilterLocation(null); - setServerIdFilter(''); - setUuidFilter(''); - setExternalIdFilter(''); - setPagination((p) => ({ ...p, page: 1 })); + resetFilters(); }} > {t('admin.servers.filters.clear')} @@ -744,20 +707,20 @@ export default function ServersPage() { variant='outline' size='sm' disabled={!pagination.hasPrev} - onClick={() => setPagination((p) => ({ ...p, page: p.page - 1 }))} + onClick={() => patchFilters({ page: page - 1 })} className='gap-1.5' > {t('common.previous')} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages}
    + ) : ( + System + ) + } icon={Server} badges={badges} description={ @@ -898,18 +886,18 @@ export default function ServersPage() { variant='outline' size='icon' disabled={!pagination.hasPrev} - onClick={() => setPagination((p) => ({ ...p, page: p.page - 1 }))} + onClick={() => patchFilters({ page: page - 1 })} > - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} @@ -1152,6 +1140,12 @@ export default function ServersPage() { icon={User} title={t('admin.servers.details.labels.owner')} name={selectedServer?.owner?.username} + avatarUrl={resolveAvatarSrc(selectedServer?.owner?.avatar)} + nameHref={ + selectedServer?.owner?.uuid + ? `/admin/users/${selectedServer.owner.uuid}/edit` + : undefined + } detail={selectedServer?.owner?.email} secondary={ selectedServer?.owner?.last_seen @@ -1290,9 +1284,11 @@ export default function ServersPage() {
    - !open && !isInitiatingTransfer && setIsTransferDialogOpen(false)} - > - - - - - {t('admin.servers.transfer.title')} - - {t('admin.servers.transfer.description')} - - - - -
    - {transferServer && ( -
    -
    -

    - {t('admin.servers.transfer.server')} -

    -

    {transferServer.name}

    -
    -
    -

    - {t('admin.servers.transfer.current_node')} -

    -

    {transferServer.node?.name || 'Unknown'}

    -
    -
    - )} - -
    -
    - - -
    - - - - {!transferAutoAllocate ? ( -
    - - -
    - ) : null} -
    - -
    -
    -

    - {t('admin.servers.transfer.warning_banner')} -

    -

    - {t('admin.servers.transfer.warning_text')} -

    -
    - -
    -

    - {t('admin.servers.transfer.beta_title')} -

    -
      -
    • {t('admin.servers.transfer.beta_item1')}
    • -
    • {t('admin.servers.transfer.beta_item2')}
    • -
    • {t('admin.servers.transfer.beta_item5')}
    • -
    • {t('admin.servers.transfer.beta_item7')}
    • -
    • {t('admin.servers.transfer.beta_item8')}
    • -
    -
    -
    -
    - - - - {t('common.cancel')} - - - -
    -
    - - setIsNodeModalOpen(false)} - title={t('admin.servers.transfer.destination_node')} - > -
    -
    - - { - setNodeSearch(e.target.value); - fetchNodes(e.target.value); - }} - className='h-11 pl-10' - /> -
    -
    - {loadingNodes ? ( -
    - -
    - ) : nodesList.length === 0 ? ( -
    {t('common.no_results')}
    - ) : ( - nodesList.map((node) => ( - - )) - )} -
    -
    -
    - - setIsAllocationModalOpen(false)} - title={t('admin.servers.transfer.destination_allocation')} - > -
    -
    - - { - setAllocationSearch(e.target.value); - if (selectedNode) fetchAllocations(selectedNode.id, e.target.value); - }} - className='h-11 pl-10' - /> -
    -
    - {loadingAllocations ? ( -
    - -
    - ) : allocationsList.length === 0 ? ( -
    - {t('admin.servers.transfer.no_free_allocations')} -
    - ) : ( - allocationsList.map((allc) => ( - - )) - )} -
    -
    -
    + onOpenChange={setIsTransferDialogOpen} + onCompleted={() => setRefreshKey((prev) => prev + 1)} + />
    ); } @@ -1954,6 +1684,8 @@ function RelationCard({ icon: Icon, title, name, + nameHref, + avatarUrl, detail, secondary, secondaryTitle, @@ -1961,10 +1693,24 @@ function RelationCard({ icon: ElementType; title: string; name: string | undefined; + nameHref?: string; + avatarUrl?: string; detail?: string; secondary?: ReactNode; secondaryTitle?: string; }) { + const nameContent = + nameHref && name ? ( + + {name} + + ) : ( +

    {name || 'N/A'}

    + ); + return (
    @@ -1975,7 +1721,16 @@ function RelationCard({ {title}
    -

    {name || 'N/A'}

    + {avatarUrl ? ( +
    + + + +
    {nameContent}
    +
    + ) : ( + nameContent + )} {detail &&

    {detail}

    } {secondary && (

    diff --git a/frontendv2/src/app/(app)/admin/settings/page.tsx b/frontendv2/src/app/(app)/admin/settings/page.tsx index 8722a61ba..9a9f7e2ca 100644 --- a/frontendv2/src/app/(app)/admin/settings/page.tsx +++ b/frontendv2/src/app/(app)/admin/settings/page.tsx @@ -47,6 +47,7 @@ import { Search, X, Send, + Link2, } from 'lucide-react'; import { copyToClipboard, cn } from '@/lib/utils'; @@ -448,6 +449,8 @@ export default function SettingsPage() { case 'general': case 'app': return Settings; + case 'links': + return Link2; case 'mail': return Mail; case 'security': diff --git a/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx index d210bf798..bf198aa49 100644 --- a/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx +++ b/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx @@ -30,7 +30,19 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; -import { Sparkles, ArrowLeft, Trash2, Plus, Pencil, Settings, Container, Zap, FileCode, Terminal } from 'lucide-react'; +import { + Sparkles, + ArrowLeft, + Trash2, + Plus, + Pencil, + Settings, + Container, + Zap, + FileCode, + Terminal, + Star, +} from 'lucide-react'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; @@ -94,6 +106,7 @@ export default function EditSpellPage() { const [deletingVariable, setDeletingVariable] = useState(false); const [dockerImages, setDockerImages] = useState<{ name: string; value: string }[]>([]); + const [defaultDockerImage, setDefaultDockerImage] = useState(''); const [features, setFeatures] = useState([]); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-spells-edit'); @@ -134,6 +147,7 @@ export default function EditSpellPage() { const images = JSON.parse(dockerImagesData); setDockerImages(Object.entries(images).map(([name, value]) => ({ name, value: value as string }))); + setDefaultDockerImage(spell.default_docker_image || ''); } catch (e) { console.error('Failed to parse docker images:', e); setDockerImages([]); @@ -188,6 +202,7 @@ export default function EditSpellPage() { await axios.patch(`/api/admin/spells/${spellId}`, { ...form, docker_images: JSON.stringify(dockerImagesObj), + default_docker_image: defaultDockerImage || null, features: JSON.stringify(features), }); @@ -210,13 +225,22 @@ export default function EditSpellPage() { }; const removeDockerImage = (index: number) => { - setDockerImages(dockerImages.filter((_, i) => i !== index)); + const removed = dockerImages[index]; + const next = dockerImages.filter((_, i) => i !== index); + setDockerImages(next); + if (removed?.value && defaultDockerImage === removed.value) { + setDefaultDockerImage(next[0]?.value ?? ''); + } }; const updateDockerImage = (index: number, field: 'name' | 'value', value: string) => { const updated = [...dockerImages]; + const previousValue = updated[index].value; updated[index][field] = value; setDockerImages(updated); + if (field === 'value' && defaultDockerImage === previousValue) { + setDefaultDockerImage(value); + } }; const addFeature = () => { @@ -430,9 +454,26 @@ export default function EditSpellPage() {

    +

    + {t('admin.spells.form.default_docker_image_help')} +

    {dockerImages.map((image, index) => (
    + updateDockerImage(index, 'name', e.target.value)} diff --git a/frontendv2/src/app/(app)/admin/spells/create/page.tsx b/frontendv2/src/app/(app)/admin/spells/create/page.tsx index 3358008c7..1a2b15ee0 100644 --- a/frontendv2/src/app/(app)/admin/spells/create/page.tsx +++ b/frontendv2/src/app/(app)/admin/spells/create/page.tsx @@ -28,7 +28,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; -import { Sparkles, ArrowLeft, Trash2, Plus, Container, Zap, FileCode, Terminal } from 'lucide-react'; +import { Sparkles, ArrowLeft, Trash2, Plus, Container, Zap, FileCode, Terminal, Star } from 'lucide-react'; import { PageCard } from '@/components/featherui/PageCard'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; @@ -71,6 +71,7 @@ export default function CreateSpellPage() { }); const [dockerImages, setDockerImages] = useState<{ name: string; value: string }[]>([]); + const [defaultDockerImage, setDefaultDockerImage] = useState(''); const [features, setFeatures] = useState([]); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-spells-create'); @@ -110,6 +111,7 @@ export default function CreateSpellPage() { await axios.put('/api/admin/spells', { ...form, docker_images: JSON.stringify(dockerImagesObj), + default_docker_image: defaultDockerImage || null, features: JSON.stringify(features), }); @@ -128,17 +130,30 @@ export default function CreateSpellPage() { }; const addDockerImage = () => { - setDockerImages([...dockerImages, { name: '', value: '' }]); + const next = [...dockerImages, { name: '', value: '' }]; + setDockerImages(next); + if (!defaultDockerImage && next.length === 1) { + setDefaultDockerImage(''); + } }; const removeDockerImage = (index: number) => { - setDockerImages(dockerImages.filter((_, i) => i !== index)); + const removed = dockerImages[index]; + const next = dockerImages.filter((_, i) => i !== index); + setDockerImages(next); + if (removed?.value && defaultDockerImage === removed.value) { + setDefaultDockerImage(next[0]?.value ?? ''); + } }; const updateDockerImage = (index: number, field: 'name' | 'value', value: string) => { const updated = [...dockerImages]; + const previousValue = updated[index].value; updated[index][field] = value; setDockerImages(updated); + if (field === 'value' && defaultDockerImage === previousValue) { + setDefaultDockerImage(value); + } }; const addFeature = () => { @@ -264,9 +279,26 @@ export default function CreateSpellPage() {
    +

    + {t('admin.spells.form.default_docker_image_help')} +

    {dockerImages.map((image, index) => (
    + updateDockerImage(index, 'name', e.target.value)} diff --git a/frontendv2/src/app/(app)/admin/spells/page.tsx b/frontendv2/src/app/(app)/admin/spells/page.tsx index 8238200f5..e12ab9445 100644 --- a/frontendv2/src/app/(app)/admin/spells/page.tsx +++ b/frontendv2/src/app/(app)/admin/spells/page.tsx @@ -61,6 +61,7 @@ import { FolderTree, } from 'lucide-react'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface Spell { @@ -92,23 +93,42 @@ interface Realm { name: string; } +const SPELLS_LIST_FILTERS_KEY = 'featherpanel_admin_spells_filters_v1'; +const SPELLS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + realmId: '', + page: 1, + pageSize: 10, +}; + export default function SpellsPage() { const { t } = useTranslation(); const router = useRouter(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-spells'); const searchParams = useSearchParams(); + const urlRealmId = searchParams?.get('realm_id') ?? ''; const fileInputRef = useRef(null); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + SPELLS_LIST_FILTERS_KEY, + SPELLS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const realmIdParam = urlRealmId || filters.realmId || ''; + + useEffect(() => { + if (urlRealmId && urlRealmId !== filters.realmId) { + patchFilters({ realmId: urlRealmId, page: 1 }); + } + }, [urlRealmId, filters.realmId, patchFilters]); + const [loading, setLoading] = useState(true); const [spells, setSpells] = useState([]); const [realms, setRealms] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [currentRealm, setCurrentRealm] = useState(null); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -123,17 +143,15 @@ export default function SpellsPage() { const [reorderLoading, setReorderLoading] = useState(false); const [hasOrderChanges, setHasOrderChanges] = useState(false); - const realmIdParam = searchParams?.get('realm_id'); - useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); useEffect(() => { const fetchRealms = async () => { @@ -154,13 +172,17 @@ export default function SpellsPage() { }, [realmIdParam]); useEffect(() => { + if (!hydrated) { + return; + } + const fetchSpells = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/spells', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, realm_id: realmIdParam || undefined, }, @@ -169,8 +191,6 @@ export default function SpellsPage() { setSpells(data.data.spells || []); const apiPagination = data.data.pagination; setPagination({ - page: apiPagination.current_page, - pageSize: apiPagination.per_page, total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, @@ -186,7 +206,7 @@ export default function SpellsPage() { fetchSpells(); fetchWidgets(); - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, refreshKey, realmIdParam, t, fetchWidgets]); + }, [page, pageSize, debouncedSearchQuery, refreshKey, realmIdParam, t, fetchWidgets, hydrated]); const handleDelete = async (spell: Spell) => { if (!confirm(t('admin.spells.messages.delete_confirm'))) return; @@ -202,11 +222,11 @@ export default function SpellsPage() { const handleExport = async (spell: Spell) => { try { - const { data } = await axios.get(`/api/admin/spells/${spell.id}`); - const spellData = data.data.spell; + const response = await axios.get(`/api/admin/spells/${spell.id}/export`, { + responseType: 'blob', + }); - const blob = new Blob([JSON.stringify(spellData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); + const url = URL.createObjectURL(response.data); const a = document.createElement('a'); a.href = url; a.download = `${spell.name.toLowerCase().replace(/\s+/g, '-')}.json`; @@ -352,7 +372,7 @@ export default function SpellsPage() { toast.success(t('admin.spells.order.messages.saved')); setHasOrderChanges(false); setIsReorderMode(false); - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); setRefreshKey((prev) => prev + 1); } catch { toast.error(t('admin.spells.order.messages.save_failed')); @@ -366,7 +386,7 @@ export default function SpellsPage() { const ok = await fetchAllSpellsForReorder(); if (!ok) return; } else { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); setRefreshKey((prev) => prev + 1); setHasOrderChanges(false); } @@ -432,7 +452,7 @@ export default function SpellsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    @@ -462,20 +482,20 @@ export default function SpellsPage() { variant='outline' size='sm' disabled={!pagination.hasPrev} - onClick={() => setPagination((p) => ({ ...p, page: p.page - 1 }))} + onClick={() => patchFilters({ page: page - 1 })} className='gap-1.5' > {t('common.previous')} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/subdomains/page.tsx b/frontendv2/src/app/(app)/admin/subdomains/page.tsx index 76e49078b..9eafbaa28 100644 --- a/frontendv2/src/app/(app)/admin/subdomains/page.tsx +++ b/frontendv2/src/app/(app)/admin/subdomains/page.tsx @@ -52,6 +52,7 @@ import { import { toast } from 'sonner'; import axios, { isAxiosError } from 'axios'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; export interface SubdomainSpellMapping { @@ -147,21 +148,30 @@ interface Pagination { hasPrev: boolean; } +const SUBDOMAINS_LIST_FILTERS_KEY = 'featherpanel_admin_subdomains_filters_v1'; +const SUBDOMAINS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + page: 1, + pageSize: 10, +}; + export default function AdminSubdomainsPage() { const { t } = useTranslation(); const router = useRouter(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-subdomains'); const [loading, setLoading] = useState(true); const [domains, setDomains] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const { filters, patchFilters, hydrated } = usePersistedListFilters( + SUBDOMAINS_LIST_FILTERS_KEY, + SUBDOMAINS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, hasPrev: false, }); - const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [manageOpen, setManageOpen] = useState(false); @@ -205,21 +215,25 @@ export default function AdminSubdomainsPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); const fetchDomains = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get<{ success: boolean; data: SubdomainAdminResponse }>( '/api/admin/subdomains', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, includeInactive: true, }, @@ -228,8 +242,6 @@ export default function AdminSubdomainsPage() { const result = data.data; setDomains(result.domains || []); setPagination({ - page: result.pagination.current_page, - pageSize: result.pagination.per_page, total: result.pagination.total_records, totalPages: result.pagination.total_pages, hasNext: result.pagination.current_page < result.pagination.total_pages, @@ -241,7 +253,7 @@ export default function AdminSubdomainsPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, t]); + }, [page, pageSize, debouncedSearchQuery, t, hydrated]); const fetchInitialData = useCallback(async () => { try { @@ -521,7 +533,7 @@ export default function AdminSubdomainsPage() { )} {userSubdomainsEnabled && ( - + {t('admin.subdomains.featureEnabledAlertTitle')} @@ -585,7 +597,7 @@ export default function AdminSubdomainsPage() { className='h-11 w-full pl-10' placeholder={t('admin.subdomains.searchPlaceholder')} value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} />
    @@ -601,21 +613,21 @@ export default function AdminSubdomainsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/tickets/[uuid]/components/TicketSidebar.tsx b/frontendv2/src/app/(app)/admin/tickets/[uuid]/components/TicketSidebar.tsx index 172e82e5b..73113e2c2 100644 --- a/frontendv2/src/app/(app)/admin/tickets/[uuid]/components/TicketSidebar.tsx +++ b/frontendv2/src/app/(app)/admin/tickets/[uuid]/components/TicketSidebar.tsx @@ -20,6 +20,7 @@ import { Card } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Clock, Eye, Info, Mail, RefreshCw, Server, Settings, TicketIcon, User } from 'lucide-react'; import Link from 'next/link'; +import { RoleBadge } from '@/components/RoleBadge'; import { cn } from '@/lib/utils'; import { Ticket, UserData, UserMail } from '../page'; import React from 'react'; @@ -95,15 +96,12 @@ export function TicketSidebar({
    {userDetails.role && ( - - {userDetails.role.display_name || userDetails.role.name} - + )} {userDetails.banned === 'true' && ( diff --git a/frontendv2/src/app/(app)/admin/tickets/[uuid]/page.tsx b/frontendv2/src/app/(app)/admin/tickets/[uuid]/page.tsx index 587b284eb..390440547 100644 --- a/frontendv2/src/app/(app)/admin/tickets/[uuid]/page.tsx +++ b/frontendv2/src/app/(app)/admin/tickets/[uuid]/page.tsx @@ -31,6 +31,7 @@ import ReactMarkdown from 'react-markdown'; import Link from 'next/link'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { RoleBadge } from '@/components/RoleBadge'; import { cn } from '@/lib/utils'; import { formatBytes } from '@/lib/format'; import { Sheet, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; @@ -533,18 +534,10 @@ export default function TicketViewPage() { : t('admin.tickets.view.user'))} {msg.user?.role && ( - - {msg.user.role.name} - + )} {new Date(msg.created_at).toLocaleTimeString([], { diff --git a/frontendv2/src/app/(app)/admin/tickets/page.tsx b/frontendv2/src/app/(app)/admin/tickets/page.tsx index 80934db4d..0bed33718 100644 --- a/frontendv2/src/app/(app)/admin/tickets/page.tsx +++ b/frontendv2/src/app/(app)/admin/tickets/page.tsx @@ -31,6 +31,7 @@ import { Select } from '@/components/ui/select-native'; import { PageCard } from '@/components/featherui/PageCard'; import { AlertCircle } from 'lucide-react'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; interface User { @@ -71,6 +72,15 @@ interface Pagination { hasPrev: boolean; } +const TICKETS_LIST_FILTERS_KEY = 'featherpanel_admin_tickets_filters_v1'; +const TICKETS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + statusFilter: 'all', + categoryFilter: 'all', + page: 1, + pageSize: 10, +}; + export default function TicketsPage() { const { t } = useTranslation(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-tickets'); @@ -78,13 +88,13 @@ export default function TicketsPage() { const [categories, setCategories] = useState([]); const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [categoryFilter, setCategoryFilter] = useState('all'); + const { filters, patchFilters, hydrated } = usePersistedListFilters( + TICKETS_LIST_FILTERS_KEY, + TICKETS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, statusFilter, categoryFilter, page, pageSize } = filters; - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -99,12 +109,12 @@ export default function TicketsPage() { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((prev) => ({ ...prev, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, debouncedSearchQuery, patchFilters]); useEffect(() => { const fetchDependencies = async () => { @@ -123,14 +133,18 @@ export default function TicketsPage() { }, []); useEffect(() => { + if (!hydrated) { + return; + } + const controller = new AbortController(); const fetchTickets = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/tickets', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, status_id: statusFilter !== 'all' ? statusFilter : undefined, category_id: categoryFilter !== 'all' ? categoryFilter : undefined, @@ -141,15 +155,12 @@ export default function TicketsPage() { setTickets(data.data.tickets || []); const apiPagination = data.data.pagination; if (apiPagination) { - setPagination((prev) => ({ - ...prev, - page: apiPagination.current_page, - pageSize: apiPagination.per_page, + setPagination({ total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, hasPrev: apiPagination.has_prev, - })); + }); } } catch (error) { if (!axios.isCancel(error)) { @@ -168,16 +179,7 @@ export default function TicketsPage() { return () => { controller.abort(); }; - }, [ - pagination.page, - pagination.pageSize, - debouncedSearchQuery, - statusFilter, - categoryFilter, - refreshKey, - t, - fetchWidgets, - ]); + }, [page, pageSize, debouncedSearchQuery, statusFilter, categoryFilter, refreshKey, t, fetchWidgets, hydrated]); const handleDelete = async (uuid: string, id: number) => { if (!confirm(t('admin.tickets.messages.delete_confirm'))) return; @@ -206,7 +208,7 @@ export default function TicketsPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    @@ -216,8 +218,7 @@ export default function TicketsPage() { { - setCategoryFilter(e.target.value); - setPagination((prev) => ({ ...prev, page: 1 })); + patchFilters({ categoryFilter: e.target.value, page: 1 }); }} className='bg-background/50 border-border/50 h-11 w-[160px] rounded-xl' > @@ -254,21 +254,21 @@ export default function TicketsPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages}
    - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages}
    diff --git a/frontendv2/src/app/(app)/admin/updates/page.tsx b/frontendv2/src/app/(app)/admin/updates/page.tsx index ae55671c6..29355d8d5 100644 --- a/frontendv2/src/app/(app)/admin/updates/page.tsx +++ b/frontendv2/src/app/(app)/admin/updates/page.tsx @@ -250,21 +250,59 @@ export default function AdminUpdatesPage() { setIsBulkUpdating(true); const toastId = toast.loading(t('admin_updates.messages.bulk_starting')); + const pluginIdentifiers = Array.from(selectedPlugins); + const queuedIdentifiers = pluginIdentifiers; + const pluginFailures: string[] = []; + let pluginsUpdated = 0; + try { - const nodeUpdates = Array.from(selectedNodes).map((id) => - axios.post(`/api/admin/nodes/${id}/self-update`, { source: 'github' }), + const nodeResults = await Promise.allSettled( + Array.from(selectedNodes).map((id) => + axios.post(`/api/admin/nodes/${id}/self-update`, { source: 'github' }), + ), ); - const pluginUpdatesReq = Array.from(selectedPlugins).map((identifier) => - axios.post('/api/admin/cloud-plugins/install', { identifier }), - ); + for (const identifier of pluginIdentifiers) { + try { + await axios.post('/api/admin/plugins/online/install', { + identifier, + queued_identifiers: queuedIdentifiers, + }); + pluginsUpdated += 1; + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data?.message || t('admin.plugins.messages.update_failed') + : t('admin.plugins.messages.update_failed'); + const plugin = plugins.find((p) => p.identifier === identifier); + pluginFailures.push(`${plugin?.name || identifier}: ${message}`); + } + } - await Promise.allSettled([...nodeUpdates, ...pluginUpdatesReq]); + const nodeFailures = nodeResults.filter((r) => r.status === 'rejected').length; + const hasFailures = nodeFailures > 0 || pluginFailures.length > 0; + + if (!hasFailures) { + toast.success(t('admin_updates.messages.bulk_started'), { id: toastId }); + } else if (pluginsUpdated > 0 || nodeResults.some((r) => r.status === 'fulfilled')) { + toast.error(t('admin_updates.messages.bulk_failed'), { id: toastId }); + if (pluginFailures.length > 0) { + console.error('Plugin updates failed:', pluginFailures); + } + } else { + toast.error(t('admin_updates.messages.bulk_failed'), { id: toastId }); + if (pluginFailures.length > 0) { + console.error('Plugin updates failed:', pluginFailures); + } + } - toast.success(t('admin_updates.messages.bulk_started'), { id: toastId }); setSelectedNodes(new Set()); setSelectedPlugins(new Set()); - checkAllUpdates(); + await checkAllUpdates(); + + if (pluginsUpdated > 0) { + await fetchPlugins(); + setTimeout(() => window.location.reload(), 1500); + } } catch (error) { console.error(error); toast.error(t('admin_updates.messages.bulk_failed'), { id: toastId }); diff --git a/frontendv2/src/app/(app)/admin/users/[uuid]/edit/page.tsx b/frontendv2/src/app/(app)/admin/users/[uuid]/edit/page.tsx index 5c2d41e4c..2d361205f 100644 --- a/frontendv2/src/app/(app)/admin/users/[uuid]/edit/page.tsx +++ b/frontendv2/src/app/(app)/admin/users/[uuid]/edit/page.tsx @@ -21,6 +21,7 @@ import { useTranslation } from '@/contexts/TranslationContext'; import axios from 'axios'; import { User, + Users, Shield, Mail, Server as ServerIcon, @@ -32,6 +33,7 @@ import { ArrowLeft, Edit, RefreshCw, + RotateCcw, Copy, ExternalLink, AlertTriangle, @@ -69,10 +71,14 @@ import { copyToClipboard } from '@/lib/utils'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { useSettings } from '@/contexts/SettingsContext'; +import { useDateFormatOptions } from '@/contexts/PreferencesContext'; +import { formatDateTimeInTz, formatRelativeTime } from '@/lib/dateUtils'; +import { RoleBadge } from '@/components/RoleBadge'; interface UserRole { name: string; display_name: string; + custom_badge?: string | null; color: string; } @@ -110,7 +116,7 @@ interface ApiUser { ldap_provider_uuid?: string | null; ldap_dn?: string | null; activities?: { name: string; context: string; ip_address: string; created_at: string }[]; - mails?: { subject: string; status: string; created_at: string; body?: string }[]; + mails?: { id: number; subject: string; status: string; created_at: string; body?: string }[]; } interface EditForm { @@ -145,6 +151,23 @@ interface VmInstance { created_at?: string; } +interface PotentialAlt { + uuid: string; + username: string; + email?: string; + avatar: string; + banned?: string; + last_seen?: string; + first_ip?: string; + last_ip?: string; + role?: UserRole; + shared_ips: string[]; + shared_devices: string[]; + match_reasons: string[]; + match_count: number; + confidence?: 'high' | 'medium' | 'low'; +} + interface AvailableRole { id: string; name: string; @@ -157,6 +180,7 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin const router = useRouter(); const resolvedParams = use(params); const { settings } = useSettings(); + const dateOpts = useDateFormatOptions(); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -164,6 +188,10 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin const [availableRoles, setAvailableRoles] = useState([]); const [ownedServers, setOwnedServers] = useState([]); const [ownedVms, setOwnedVms] = useState([]); + const [potentialAlts, setPotentialAlts] = useState([]); + const [altSourceIps, setAltSourceIps] = useState([]); + const [altSourceDevices, setAltSourceDevices] = useState([]); + const [clearingDevices, setClearingDevices] = useState(false); const [ssoGenerating, setSsoGenerating] = useState(false); const [ssoLink, setSsoLink] = useState(null); const [mailPreview, setMailPreview] = useState<{ @@ -176,6 +204,7 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin const [sendEmailOpen, setSendEmailOpen] = useState(false); const [sendingEmail, setSendingEmail] = useState(false); const [sendEmailData, setSendEmailData] = useState({ subject: '', body: '' }); + const [resendingMailId, setResendingMailId] = useState(null); const [banDialogOpen, setBanDialogOpen] = useState(false); const [banSubmitting, setBanSubmitting] = useState(false); const [banReason, setBanReason] = useState({ @@ -258,6 +287,17 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin } catch { setOwnedVms([]); } + + try { + const altsRes = await axios.get(`/api/admin/users/${resolvedParams.uuid}/potential-alts`); + setPotentialAlts(altsRes.data?.data?.potential_alts || []); + setAltSourceIps(altsRes.data?.data?.source_ips || []); + setAltSourceDevices(altsRes.data?.data?.source_devices || []); + } catch { + setPotentialAlts([]); + setAltSourceIps([]); + setAltSourceDevices([]); + } } catch { toast.error(t('admin.users.edit.error')); setUser(null); @@ -271,6 +311,36 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedParams.uuid]); + const clearUserDevices = async () => { + if (!user) return; + if ( + !confirm( + t('admin.users.edit.potential_alts.clear_user_confirm', { + username: user.username, + }), + ) + ) { + return; + } + + setClearingDevices(true); + try { + const { data } = await axios.delete(`/api/admin/users/${user.uuid}/devices`); + if (data?.success) { + toast.success(t('admin.users.edit.potential_alts.clear_user_success')); + setPotentialAlts([]); + setAltSourceDevices([]); + await fetchUser(); + } else { + toast.error(data?.message || t('admin.users.edit.potential_alts.clear_failed')); + } + } catch { + toast.error(t('admin.users.edit.potential_alts.clear_failed')); + } finally { + setClearingDevices(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user) return; @@ -442,6 +512,29 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin setMailPreviewOpen(true); }; + const handleResendMail = async (mail: { id: number; subject: string; status: string }) => { + if (!user) return; + + setResendingMailId(mail.id); + try { + const { data } = await axios.post(`/api/admin/users/${user.uuid}/mails/${mail.id}/resend`); + if (data?.success) { + toast.success(t('admin.users.edit.mails.resend_success')); + await fetchUser(); + } else { + toast.error(data?.message || t('admin.users.edit.mails.resend_failed')); + } + } catch (error: unknown) { + const message = + axios.isAxiosError(error) && error.response?.data?.message + ? String(error.response.data.message) + : t('admin.users.edit.mails.resend_failed'); + toast.error(message); + } finally { + setResendingMailId(null); + } + }; + const handleSendEmail = async (e: React.FormEvent) => { e.preventDefault(); if (!user) return; @@ -660,16 +753,11 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin

    {user.email}

    - - {user.role?.display_name || user.role?.name || '-'} - + {user.role ? ( + + ) : ( + - + )} {user.banned === 'true' ? t('admin.users.badges.banned') @@ -718,13 +806,17 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin {t('admin.users.edit.account_info.created')} - {user.created_at || user.first_seen} + + {formatDateTimeInTz(user.created_at || user.first_seen, dateOpts)} +
    {t('admin.users.edit.account_info.last_seen')} - {user.last_seen || '-'} + + {user.last_seen ? formatRelativeTime(user.last_seen, dateOpts) : '-'} +
    {user.last_ip && (
    @@ -856,6 +948,15 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin {t('admin.users.edit.tabs.activities')} + + + {t('admin.users.edit.tabs.potential_alts')} + {potentialAlts.length > 0 && ( + + {potentialAlts.length} + + )} + {t('admin.users.edit.tabs.vds', { defaultValue: 'VDS' })} @@ -927,7 +1028,9 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin {server.status || t('admin.users.edit.servers.offline')} - {server.created_at} + + {formatDateTimeInTz(server.created_at, dateOpts)} +
    + + + + + + + {t('admin.users.edit.potential_alts.clear_user')} + + } + > +

    + {t('admin.users.edit.potential_alts.description')} +

    + {altSourceIps.length > 0 && ( +
    +

    + {t('admin.users.edit.potential_alts.source_ips')} +

    +
    + {altSourceIps.map((ip) => ( + + {ip} + + ))} +
    +
    + )} + {altSourceDevices.length > 0 && ( +
    +

    + {t('admin.users.edit.potential_alts.source_devices')} +

    +
    + {altSourceDevices.map((device) => ( + + {device.slice(0, 12)} + + ))} +
    +
    + )} +
    + + + + + + + + + + + + + {potentialAlts.length === 0 ? ( + + + + ) : ( + potentialAlts.map((alt) => ( + + + + + + + )) )} @@ -1135,28 +1451,51 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin ) : ( - user.mails.map((mail, index) => ( + user.mails.map((mail) => ( - + )) @@ -1173,7 +1512,8 @@ export default function UserEditPage({ params }: { params: Promise<{ uuid: strin {mailPreview?.subject} - {mailPreview?.created_at} | {mailPreview?.status} + {mailPreview?.created_at ? formatDateTimeInTz(mailPreview.created_at, dateOpts) : '—'} |{' '} + {mailPreview?.status}
    diff --git a/frontendv2/src/app/(app)/admin/users/page.tsx b/frontendv2/src/app/(app)/admin/users/page.tsx index 9c2d673d0..edd5a5907 100644 --- a/frontendv2/src/app/(app)/admin/users/page.tsx +++ b/frontendv2/src/app/(app)/admin/users/page.tsx @@ -15,11 +15,13 @@ See the LICENSE file or . 'use client'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useTranslation } from '@/contexts/TranslationContext'; import { useDateFormatOptions } from '@/contexts/PreferencesContext'; import axios from 'axios'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { Users as UsersIcon, Shield, @@ -27,8 +29,6 @@ import { Search, Eye, Trash2, - ChevronLeft, - ChevronRight, AlertCircle, UserPlus, CheckCircle2, @@ -39,6 +39,7 @@ import { TableSkeleton } from '@/components/featherui/TableSkeleton'; import { EmptyState } from '@/components/featherui/EmptyState'; import { Button } from '@/components/featherui/Button'; import { Input } from '@/components/featherui/Input'; +import { ListPagination } from '@/components/featherui/ListPagination'; import { PageCard } from '@/components/featherui/PageCard'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Select } from '@/components/ui/select-native'; @@ -46,10 +47,13 @@ import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { toast } from 'sonner'; import { formatDateTimeInTz, formatRelativeTime } from '@/lib/dateUtils'; +import { getRoleBadgeStyles } from '@/components/RoleBadge'; +import { getRoleBadgeLabel } from '@/lib/role-utils'; interface UserRole { name: string; display_name: string; + custom_badge?: string | null; color: string; } @@ -64,6 +68,7 @@ interface ApiUser { banned?: string; two_fa_enabled?: string; last_seen?: string; + first_seen?: string; created_at?: string; discord_oauth2_id?: string | null; discord_oauth2_linked?: string; @@ -89,22 +94,129 @@ interface AvailableRole { id: string; name: string; display_name: string; + custom_badge?: string | null; color: string; } +const USERS_LIST_FILTERS_KEY = 'featherpanel_admin_users_filters_v2'; + +const USERS_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + roleFilter: '', + bannedFilter: '', + ipFilter: '', + emailVerifiedFilter: '' as '' | 'true' | 'false', + sortBy: 'created_at' as 'id' | 'username' | 'email' | 'last_seen' | 'created_at', + sortOrder: 'DESC' as 'ASC' | 'DESC', + page: 1, + pageSize: 15, +}; + +type UsersListFilters = typeof USERS_LIST_FILTERS_DEFAULTS; + +function filtersToQueryString(filters: UsersListFilters): string { + const params = new URLSearchParams(); + + if (filters.searchQuery) { + params.set('q', filters.searchQuery); + } + if (filters.page > 1) { + params.set('page', String(filters.page)); + } + if (filters.roleFilter) { + params.set('role', filters.roleFilter); + } + if (filters.bannedFilter) { + params.set('banned', filters.bannedFilter); + } + if (filters.ipFilter) { + params.set('ip', filters.ipFilter); + } + if (filters.emailVerifiedFilter) { + params.set('email_verified', filters.emailVerifiedFilter); + } + if (filters.sortBy !== USERS_LIST_FILTERS_DEFAULTS.sortBy) { + params.set('sort_by', filters.sortBy); + } + if (filters.sortOrder !== USERS_LIST_FILTERS_DEFAULTS.sortOrder) { + params.set('sort_order', filters.sortOrder); + } + + return params.toString(); +} + +function filtersFromSearchParams(searchParams: URLSearchParams): Partial { + const partial: Partial = {}; + + const q = searchParams.get('q'); + if (q !== null) { + partial.searchQuery = q; + } + + const page = searchParams.get('page'); + if (page !== null) { + const parsedPage = Number.parseInt(page, 10); + if (!Number.isNaN(parsedPage) && parsedPage > 0) { + partial.page = parsedPage; + } + } + + const role = searchParams.get('role'); + if (role !== null) { + partial.roleFilter = role; + } + + const banned = searchParams.get('banned'); + if (banned !== null) { + partial.bannedFilter = banned; + } + + const ip = searchParams.get('ip'); + if (ip !== null) { + partial.ipFilter = ip; + } + + const emailVerified = searchParams.get('email_verified'); + if (emailVerified === 'true' || emailVerified === 'false') { + partial.emailVerifiedFilter = emailVerified; + } + + const sortBy = searchParams.get('sort_by'); + if ( + sortBy === 'id' || + sortBy === 'username' || + sortBy === 'email' || + sortBy === 'last_seen' || + sortBy === 'created_at' + ) { + partial.sortBy = sortBy; + } + + const sortOrder = searchParams.get('sort_order'); + if (sortOrder === 'ASC' || sortOrder === 'DESC') { + partial.sortOrder = sortOrder; + } + + return partial; +} + export default function UsersPage() { const { t } = useTranslation(); const dateOpts = useDateFormatOptions(); const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const skipUrlSyncRef = useRef(false); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - const [bannedFilter, setBannedFilter] = useState(''); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 15, + const { filters, patchFilters, resetFilters, setFilters, hydrated } = usePersistedListFilters( + USERS_LIST_FILTERS_KEY, + USERS_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, roleFilter, bannedFilter, ipFilter, emailVerifiedFilter, sortBy, sortOrder, page, pageSize } = + filters; + const [pagination, setPagination] = useState>({ total: 0, from: 0, to: 0, @@ -114,8 +226,10 @@ export default function UsersPage() { const [availableRoles, setAvailableRoles] = useState([]); const [refreshKey, setRefreshKey] = useState(0); + const [clearingAllDevices, setClearingAllDevices] = useState(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + const [debouncedIpFilter, setDebouncedIpFilter] = useState(''); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-users'); @@ -123,29 +237,81 @@ export default function UsersPage() { fetchWidgets(); }, [fetchWidgets]); + useEffect(() => { + if (!hydrated) { + return; + } + + if (skipUrlSyncRef.current) { + skipUrlSyncRef.current = false; + return; + } + + const fromUrl = filtersFromSearchParams(searchParams); + if (Object.keys(fromUrl).length === 0) { + return; + } + + setFilters((prev) => ({ ...prev, ...fromUrl })); + }, [hydrated, searchParams, setFilters]); + + useEffect(() => { + if (!hydrated) { + return; + } + + const nextQuery = filtersToQueryString(filters); + const currentQuery = searchParams.toString(); + if (nextQuery === currentQuery) { + return; + } + + skipUrlSyncRef.current = true; + router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false }); + }, [filters, hydrated, pathname, router, searchParams]); + useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); if (searchQuery !== debouncedSearchQuery) { - setPagination((prev) => ({ ...prev, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [debouncedSearchQuery, searchQuery]); + }, [debouncedSearchQuery, patchFilters, searchQuery]); useEffect(() => { + const timer = setTimeout(() => { + setDebouncedIpFilter(ipFilter); + if (ipFilter !== debouncedIpFilter) { + patchFilters({ page: 1 }); + } + }, 500); + + return () => clearTimeout(timer); + }, [debouncedIpFilter, ipFilter, patchFilters]); + + useEffect(() => { + if (!hydrated) { + return; + } + const controller = new AbortController(); const fetchUsers = async () => { setLoading(true); try { const { data } = await axios.get('/api/admin/users', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearchQuery || undefined, role: roleFilter || undefined, banned: bannedFilter || undefined, + ip: debouncedIpFilter || undefined, + email_verified: emailVerifiedFilter || undefined, + sort_by: sortBy, + sort_order: sortOrder, }, signal: controller.signal, }); @@ -154,15 +320,12 @@ export default function UsersPage() { setUsers(data.data.users || []); setAvailableRoles(data.data.roles || []); const apiPagination = data.data.pagination; - setPagination((prev) => ({ - ...prev, - page: apiPagination.current_page, - pageSize: apiPagination.per_page, + setPagination({ total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), - hasNext: apiPagination.has_next, - hasPrev: apiPagination.has_prev, - })); + from: apiPagination.from ?? 0, + to: apiPagination.to ?? 0, + }); if (data.data.roles) { setAvailableRoles( Object.entries(data.data.roles).map(([id, role]) => { @@ -196,7 +359,20 @@ export default function UsersPage() { return () => { controller.abort(); }; - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, roleFilter, refreshKey, t, bannedFilter]); + }, [ + page, + pageSize, + debouncedSearchQuery, + debouncedIpFilter, + roleFilter, + refreshKey, + t, + bannedFilter, + emailVerifiedFilter, + sortBy, + sortOrder, + hydrated, + ]); const handleDeleteUser = async (user: ApiUser) => { if (!confirm(t('admin.users.messages.delete_confirm', { username: user.username }))) { @@ -240,33 +416,25 @@ export default function UsersPage() { } }; - const paginationBar = ( -
    - - - {pagination ? `${pagination.page} / ${pagination.totalPages}` : '—'} - - -
    - ); + const handleClearAllDevices = async () => { + if (!confirm(t('admin.users.clear_all_devices_confirm'))) { + return; + } + + setClearingAllDevices(true); + try { + const { data } = await axios.delete('/api/admin/devices'); + if (data?.success) { + toast.success(t('admin.users.clear_all_devices_success')); + } else { + toast.error(data?.message || t('admin.users.clear_all_devices_failed')); + } + } catch { + toast.error(t('admin.users.clear_all_devices_failed')); + } finally { + setClearingAllDevices(false); + } + }; return (
    @@ -277,10 +445,16 @@ export default function UsersPage() { description={t('admin.users.subtitle')} icon={UsersIcon} actions={ - +
    + + +
    } /> @@ -293,16 +467,21 @@ export default function UsersPage() { placeholder={t('admin.users.search_placeholder')} className='h-11 pl-10' value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} />
    + patchFilters({ ipFilter: e.target.value })} + /> {availableRoles.length > 0 && ( { - setBannedFilter(e.target.value); - setPagination({ ...pagination, page: 1 }); + patchFilters({ bannedFilter: e.target.value, page: 1 }); }} className='h-11 w-[160px] rounded-xl' > @@ -326,12 +504,55 @@ export default function UsersPage() { + +
    - {pagination && pagination.totalPages > 1 && paginationBar} + {pagination.totalPages > 1 && ( + patchFilters({ page: nextPage })} + /> + )} {loading ? ( @@ -344,9 +565,7 @@ export default function UsersPage() { {isEmailUnverified && ( } - onClick={() => router.push(`/admin/users/${user.uuid}/edit`)} /> ); })} )} - {pagination && pagination.totalPages > 1 &&
    {paginationBar}
    } + {pagination.totalPages > 1 && ( +
    + patchFilters({ page: nextPage })} + className='w-full max-w-xl' + /> +
    + )}
    diff --git a/frontendv2/src/app/(app)/admin/vds-nodes/page.tsx b/frontendv2/src/app/(app)/admin/vds-nodes/page.tsx index 8778a199d..1a80ab792 100644 --- a/frontendv2/src/app/(app)/admin/vds-nodes/page.tsx +++ b/frontendv2/src/app/(app)/admin/vds-nodes/page.tsx @@ -27,6 +27,7 @@ import { TableSkeleton } from '@/components/featherui/TableSkeleton'; import { EmptyState } from '@/components/featherui/EmptyState'; import { PageCard } from '@/components/featherui/PageCard'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { toast } from 'sonner'; import { @@ -77,28 +78,46 @@ interface Pagination { hasPrev: boolean; } +const VDS_NODES_LIST_FILTERS_KEY = 'featherpanel_admin_vds_nodes_filters_v1'; +const VDS_NODES_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + locationId: '', + page: 1, + pageSize: 10, +}; + type ConnectionStatus = 'unknown' | 'online' | 'offline'; export default function VdsNodesPage() { const { t } = useTranslation(); const router = useRouter(); const searchParams = useSearchParams(); - const locationIdFilter = searchParams.get('location_id'); + const urlLocationId = searchParams.get('location_id') ?? ''; + + const { filters, patchFilters, hydrated } = usePersistedListFilters( + VDS_NODES_LIST_FILTERS_KEY, + VDS_NODES_LIST_FILTERS_DEFAULTS, + ); + const { searchQuery, page, pageSize } = filters; + const locationIdFilter = urlLocationId || filters.locationId || ''; + + useEffect(() => { + if (urlLocationId && urlLocationId !== filters.locationId) { + patchFilters({ locationId: urlLocationId, page: 1 }); + } + }, [urlLocationId, filters.locationId, patchFilters]); const [loading, setLoading] = useState(true); const [vmNodes, setVmNodes] = useState([]); const [locations, setLocations] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + const [searchQueryDebounced, setDebouncedSearchQuery] = useState(''); const [connectionStatus, setConnectionStatus] = useState>({}); const [isCheckingConnections, setIsCheckingConnections] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -114,12 +133,12 @@ export default function VdsNodesPage() { useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); - if (searchQuery !== debouncedSearchQuery) { - setPagination((p) => ({ ...p, page: 1 })); + if (searchQuery !== searchQueryDebounced) { + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearchQuery]); + }, [searchQuery, searchQueryDebounced, patchFilters]); useEffect(() => { const fetchLocations = async () => { @@ -173,13 +192,17 @@ export default function VdsNodesPage() { ); const fetchVmNodes = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get('/api/admin/vm-nodes', { params: { - page: pagination.page, - limit: pagination.pageSize, - search: debouncedSearchQuery || undefined, + page, + limit: pageSize, + search: searchQueryDebounced || undefined, location_id: locationIdFilter || undefined, }, }); @@ -188,8 +211,6 @@ export default function VdsNodesPage() { setVmNodes(fetchedNodes); const apiPagination = data.data.pagination; setPagination({ - page: apiPagination.current_page, - pageSize: apiPagination.per_page, total: apiPagination.total_records, totalPages: Math.ceil(apiPagination.total_records / apiPagination.per_page), hasNext: apiPagination.has_next, @@ -206,7 +227,7 @@ export default function VdsNodesPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearchQuery, locationIdFilter, t, testAllConnections]); + }, [page, pageSize, searchQueryDebounced, locationIdFilter, t, testAllConnections, hydrated]); useEffect(() => { fetchVmNodes(); @@ -284,7 +305,7 @@ export default function VdsNodesPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' />
    @@ -297,21 +318,21 @@ export default function VdsNodesPage() { - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} diff --git a/frontendv2/src/app/(app)/admin/vm-instances/page.tsx b/frontendv2/src/app/(app)/admin/vm-instances/page.tsx index b8a4cb805..d1b293190 100644 --- a/frontendv2/src/app/(app)/admin/vm-instances/page.tsx +++ b/frontendv2/src/app/(app)/admin/vm-instances/page.tsx @@ -27,6 +27,7 @@ import { TableSkeleton } from '@/components/featherui/TableSkeleton'; import { EmptyState } from '@/components/featherui/EmptyState'; import { PageCard } from '@/components/featherui/PageCard'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; +import { usePersistedListFilters } from '@/hooks/usePersistedListFilters'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { toast } from 'sonner'; import { @@ -118,20 +119,48 @@ function formatDisk(gb: number): string { return `${gb} GB`; } +const VM_INSTANCES_LIST_FILTERS_KEY = 'featherpanel_admin_vm_instances_filters_v1'; +const VM_INSTANCES_LIST_FILTERS_DEFAULTS = { + searchQuery: '', + ownerFilter: '', + nodeFilter: '', + statusFilter: '', + sortBy: 'id' as 'id' | 'hostname' | 'created_at', + sortOrder: 'DESC' as 'ASC' | 'DESC', + page: 1, + pageSize: 10, + filterOwner: null as User | null, + filterNode: null as VmNode | null, +}; + export default function VmInstancesPage() { const { t } = useTranslation(); const router = useRouter(); + const { filters, patchFilters, resetFilters, hydrated } = usePersistedListFilters( + VM_INSTANCES_LIST_FILTERS_KEY, + VM_INSTANCES_LIST_FILTERS_DEFAULTS, + ); + const { + searchQuery, + ownerFilter, + nodeFilter, + statusFilter, + sortBy, + sortOrder, + page, + pageSize, + filterOwner, + filterNode, + } = filters; + const [loading, setLoading] = useState(true); const [instances, setInstances] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 10, + const [pagination, setPagination] = useState>({ total: 0, totalPages: 0, hasNext: false, @@ -139,15 +168,6 @@ export default function VmInstancesPage() { from: 0, to: 0, }); - - const [ownerFilter, setOwnerFilter] = useState(''); - const [nodeFilter, setNodeFilter] = useState(''); - const [statusFilter] = useState(''); - const [sortBy, setSortBy] = useState<'id' | 'hostname' | 'created_at'>('id'); - const [sortOrder, setSortOrder] = useState<'ASC' | 'DESC'>('DESC'); - - const [filterOwner, setFilterOwner] = useState(null); - const [filterNode, setFilterNode] = useState(null); const [isOwnerFilterModalOpen, setIsOwnerFilterModalOpen] = useState(false); const [isNodeFilterModalOpen, setIsNodeFilterModalOpen] = useState(false); const [ownerFilterSearch, setOwnerFilterSearch] = useState(''); @@ -164,19 +184,23 @@ export default function VmInstancesPage() { const timer = setTimeout(() => { setDebouncedSearch(searchQuery); if (searchQuery !== debouncedSearch) { - setPagination((p) => ({ ...p, page: 1 })); + patchFilters({ page: 1 }); } }, 500); return () => clearTimeout(timer); - }, [searchQuery, debouncedSearch]); + }, [searchQuery, debouncedSearch, patchFilters]); const fetchInstances = useCallback(async () => { + if (!hydrated) { + return; + } + setLoading(true); try { const { data } = await axios.get('/api/admin/vm-instances', { params: { - page: pagination.page, - limit: pagination.pageSize, + page, + limit: pageSize, search: debouncedSearch || undefined, owner_id: ownerFilter || undefined, node_id: nodeFilter || undefined, @@ -187,8 +211,6 @@ export default function VmInstancesPage() { setInstances(data.data?.instances ?? []); const pag = data.data?.pagination ?? {}; setPagination({ - page: pag.current_page || 1, - pageSize: pag.per_page || 10, total: pag.total_records || 0, totalPages: Math.ceil((pag.total_records || 0) / (pag.per_page || 10)), hasNext: pag.has_next || false, @@ -202,7 +224,7 @@ export default function VmInstancesPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, debouncedSearch, ownerFilter, nodeFilter, statusFilter, t]); + }, [page, pageSize, debouncedSearch, ownerFilter, nodeFilter, statusFilter, t, hydrated]); useEffect(() => { fetchWidgets(); @@ -330,7 +352,7 @@ export default function VmInstancesPage() { setSearchQuery(e.target.value)} + onChange={(e) => patchFilters({ searchQuery: e.target.value })} className='h-11 w-full pl-10' /> @@ -372,11 +394,7 @@ export default function VmInstancesPage() { size='sm' className='h-9 text-xs' onClick={() => { - setOwnerFilter(''); - setNodeFilter(''); - setFilterOwner(null); - setFilterNode(null); - setPagination((p) => ({ ...p, page: 1 })); + resetFilters(); }} > @@ -392,8 +410,7 @@ export default function VmInstancesPage() { 'id' | 'hostname' | 'created_at', 'ASC' | 'DESC', ]; - setSortBy(field); - setSortOrder(order); + patchFilters({ sortBy: field, sortOrder: order, page: 1 }); }} className='bg-background/50 border-border/50 h-11 w-55 rounded-xl text-sm' > @@ -432,21 +449,21 @@ export default function VmInstancesPage() { variant='outline' size='sm' disabled={!pagination.hasPrev} - onClick={() => setPagination((p) => ({ ...p, page: p.page - 1 }))} + onClick={() => patchFilters({ page: page - 1 })} className='gap-1.5' > {t('common.previous')} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} {pagination.total > 0 && ` (${pagination.total} ${t('common.total')})`} - {pagination.page} / {pagination.totalPages} + {page} / {pagination.totalPages} @@ -670,10 +687,12 @@ export default function VmInstancesPage() { key={user.id} type='button' onClick={() => { - setFilterOwner(user); - setOwnerFilter(String(user.id)); + patchFilters({ + filterOwner: user, + ownerFilter: String(user.id), + page: 1, + }); setIsOwnerFilterModalOpen(false); - setPagination((p) => ({ ...p, page: 1 })); }} className='border-border/50 hover:border-primary hover:bg-primary/5 w-full rounded-xl border p-3 text-left' > @@ -707,10 +726,12 @@ export default function VmInstancesPage() { key={node.id} type='button' onClick={() => { - setFilterNode(node); - setNodeFilter(String(node.id)); + patchFilters({ + filterNode: node, + nodeFilter: String(node.id), + page: 1, + }); setIsNodeFilterModalOpen(false); - setPagination((p) => ({ ...p, page: 1 })); }} className='border-border/50 hover:border-primary hover:bg-primary/5 w-full rounded-xl border p-3 text-left' > diff --git a/frontendv2/src/app/(app)/auth/login/LoginForm.tsx b/frontendv2/src/app/(app)/auth/login/LoginForm.tsx index bbea380c8..d64ce8eb2 100644 --- a/frontendv2/src/app/(app)/auth/login/LoginForm.tsx +++ b/frontendv2/src/app/(app)/auth/login/LoginForm.tsx @@ -15,7 +15,7 @@ See the LICENSE file or . 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; @@ -31,6 +31,15 @@ import { startAuthentication } from '@simplewebauthn/browser'; import { authApi } from '@/lib/api/auth'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; +import { AuthLegalNotice } from '@/components/auth/AuthLegalNotice'; +import { + buildLoginMethodAvailability, + buildLoginPageLayout, + parseLoginDefaultMethod, + parseLoginHiddenMethods, + parseLoginMethodsOrder, + type LoginMethodId, +} from '@/lib/loginPageConfig'; /** Reject protocol-relative URLs (e.g. `//evil.com`) while allowing same-origin paths. */ function isSafeInternalRedirectPath(redirect: string | null): redirect is string { @@ -717,7 +726,8 @@ export default function LoginForm() { const [oidcProviders, setOidcProviders] = useState<{ uuid: string; name: string }[]>([]); const [ldapProviders, setLdapProviders] = useState<{ uuid: string; name: string }[]>([]); const [selectedLdapProvider, setSelectedLdapProvider] = useState(''); - const [showLdapLogin, setShowLdapLogin] = useState(false); + const [activePrimary, setActivePrimary] = useState('local'); + const userChangedPrimary = useRef(false); useEffect(() => { const fetchOidcProviders = async () => { @@ -757,25 +767,81 @@ export default function LoginForm() { const emailLoginEnabled = settings?.email_login_enabled === 'true'; const oidcEnabled = oidcProviders.length > 0; const ldapEnabled = ldapProviders.length > 0; - const showLocalLogin = !showLdapLogin && !showEmailLogin; - const isMultiStepEmailLoginFlow = emailLoginEnabled && !showLdapLogin; + const loginPageLayout = useMemo(() => { + const hidden = parseLoginHiddenMethods(settings?.login_hidden_methods); + const order = parseLoginMethodsOrder(settings?.login_methods_order); + const defaultMethod = parseLoginDefaultMethod(settings?.login_default_method); + const availability = buildLoginMethodAvailability({ + ldapEnabled, + emailLoginEnabled, + discordEnabled, + oidcEnabled, + hidePasskey: hidden.has('passkey'), + }); + return buildLoginPageLayout(order, hidden, defaultMethod, availability); + }, [settings, ldapEnabled, emailLoginEnabled, discordEnabled, oidcEnabled]); + + useEffect(() => { + if (userChangedPrimary.current) { + return; + } + setActivePrimary(loginPageLayout.primary); + }, [loginPageLayout.primary]); + + const setPrimaryPanel = (method: LoginMethodId) => { + userChangedPrimary.current = true; + setActivePrimary(method); + if (method !== 'email_code') { + setShowEmailLogin(false); + } + if (method === 'email_code' && emailLoginEnabled) { + setLoginStep('identifier'); + } + }; + + const showLdapLogin = activePrimary === 'ldap'; + const showLocalLogin = activePrimary === 'local' && !showEmailLogin; + + const isMultiStepEmailLoginFlow = emailLoginEnabled && activePrimary === 'email_code'; const isLoginMethodStep = isMultiStepEmailLoginFlow && loginStep === 'method'; const isLoginMethodPasswordStep = isLoginMethodStep && !showEmailLogin; const isEmailLoginVerifyStep = showEmailLogin && emailLoginStep === 'code'; const emailForLoginCodeSubtitle = emailLoginForm.email || identifierValue; + const renderLoginCaptchaField = () => { + if (!showCaptcha) { + return null; + } + return ( +
    + { + setForm((prev) => ({ ...prev, turnstile_token: '' })); + }} + onExpire={() => { + setForm((prev) => ({ ...prev, turnstile_token: '' })); + }} + /> +
    + ); + }; + const renderLdapLoginButton = (className = '') => ( ); + const renderSwitchToLocalButton = () => ( + + ); + + const renderSwitchToEmailCodeButton = (emailHint?: string) => ( + + ); + + const renderAltLoginMethod = ( + methodId: LoginMethodId, + options?: { compact?: boolean; passkeyUsername?: string; showEmailCode?: boolean }, + ) => { + switch (methodId) { + case 'ldap': + return
    {renderLdapLoginButton()}
    ; + case 'local': + return
    {renderSwitchToLocalButton()}
    ; + case 'passkey': + if (options?.compact) { + if (!hasPasskeys) { + return null; + } + return ( + + ); + } + return ( + + ); + case 'email_code': + if (!emailLoginEnabled) { + return null; + } + if (options?.compact) { + if (!options.showEmailCode) { + return null; + } + return ( + + ); + } + return
    {renderSwitchToEmailCodeButton()}
    ; + case 'discord': + if (!discordEnabled) { + return null; + } + return ( + + ); + case 'oidc': + if (!oidcEnabled) { + return null; + } + return ( +
    + {oidcProviders.map((provider) => ( + + ))} +
    + ); + default: + return null; + } + }; + + const renderLoginSecondarySection = (options?: { + filter?: (id: LoginMethodId) => boolean; + compact?: boolean; + passkeyUsername?: string; + showEmailCode?: boolean; + }) => { + const methods = loginPageLayout.secondary.filter(options?.filter ?? (() => true)); + const nodes = methods + .map((id) => + renderAltLoginMethod(id, { + compact: options?.compact, + passkeyUsername: options?.passkeyUsername, + showEmailCode: options?.showEmailCode, + }), + ) + .filter((node) => node !== null); + + if (nodes.length === 0) { + return null; + } + + return ( +
    +

    + {t('auth.login.or')} +

    +
    + {nodes} +
    +
    + ); + }; + + const renderOidcPrimaryPanel = () => ( +
    +
    +

    {t('auth.login.sso')}

    +
    + {oidcProviders.map((provider) => ( + + ))} + {renderLoginSecondarySection()} +
    + ); + + const renderDiscordPrimaryPanel = () => ( +
    + + {renderLoginSecondarySection()} +
    + ); + return (
    @@ -904,7 +1230,7 @@ export default function LoginForm() { {/* Multi-step login flow when email login is enabled */} - {emailLoginEnabled && !showLdapLogin ? ( + {isMultiStepEmailLoginFlow ? ( loginStep === 'identifier' ? ( // Step 1: Enter email/username
    @@ -938,7 +1264,7 @@ export default function LoginForm() {
    )} - {ldapEnabled && renderLdapLoginButton()} + {renderLoginSecondarySection()} ) : showEmailLogin ? ( // Email code login flow (when user clicked "Request Login Code") @@ -1050,23 +1376,6 @@ export default function LoginForm() { ) : ( // Step 2: Password (+ captcha) then sign-in, passkey, or email code
    - {showCaptcha && ( -
    - - setForm((prev) => ({ ...prev, turnstile_token: token })) - } - onError={() => { - setForm((prev) => ({ ...prev, turnstile_token: '' })); - }} - onExpire={() => { - setForm((prev) => ({ ...prev, turnstile_token: '' })); - }} - /> -
    - )} - + {renderLoginCaptchaField()} + - {(hasPasskeys || isEmail) && ( -
    -

    - {t('auth.login.or')} -

    -
    - {hasPasskeys && ( - - )} - {isEmail && ( - - )} -
    -
    - )} + {renderLoginSecondarySection({ + compact: true, + passkeyUsername: identifierValue, + showEmailCode: isEmail, + })} - + {renderLoginSecondarySection({ filter: (id) => id !== 'ldap' })} + + {loginPageLayout.ordered.includes('local') && ( + + )} {renderLoginError()} {success && ( @@ -1269,23 +1531,6 @@ export default function LoginForm() {

    {t('auth.emailLogin.subtitle')}

    - {showCaptcha && ( -
    - - setForm((prev) => ({ ...prev, turnstile_token: token })) - } - onError={() => { - setForm((prev) => ({ ...prev, turnstile_token: '' })); - }} - onExpire={() => { - setForm((prev) => ({ ...prev, turnstile_token: '' })); - }} - /> -
    - )} - + {renderLoginCaptchaField()} + -
    -

    - {t('auth.login.or')} -

    -
    - - {ldapEnabled && renderLdapLoginButton()} -
    -
    + {renderLoginSecondarySection()} {error && (
    @@ -1500,49 +1702,7 @@ export default function LoginForm() { - {(discordEnabled || oidcEnabled) && !isLoginMethodStep && ( - <> -
    -
    -
    -
    -
    - - {t('auth.login.or_continue')} - -
    -
    - -
    - {oidcEnabled && - oidcProviders.map((provider) => ( - - ))} - - {discordEnabled && ( - - )} -
    - - )} + {!isSsoLogin && !isDiscordLogin && } {!isLoginMethodStep && (
    diff --git a/frontendv2/src/app/(app)/auth/register/RegisterForm.tsx b/frontendv2/src/app/(app)/auth/register/RegisterForm.tsx index dee5d34d5..c833c5037 100644 --- a/frontendv2/src/app/(app)/auth/register/RegisterForm.tsx +++ b/frontendv2/src/app/(app)/auth/register/RegisterForm.tsx @@ -26,9 +26,12 @@ import { useSettings } from '@/contexts/SettingsContext'; import { useTranslation } from '@/contexts/TranslationContext'; import { Captcha } from '@/components/Captcha'; import { authApi } from '@/lib/api/auth'; +import axios from 'axios'; +import { getFeatherpanelApiErrorCode } from '@/lib/api'; import { isCaptchaConfigured, obtainCaptchaResponseToken } from '@/lib/captchaGate'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; +import { AuthLegalNotice } from '@/components/auth/AuthLegalNotice'; export default function RegisterForm() { const router = useRouter(); @@ -59,6 +62,25 @@ export default function RegisterForm() { const showCaptcha = isCaptchaConfigured(settings); const discordEnabled = settings?.discord_oauth_enabled === 'true'; + const formatRegistrationError = (err: unknown, fallbackMessage?: string): string => { + if (axios.isAxiosError(err)) { + const code = getFeatherpanelApiErrorCode(err); + const data = err.response?.data?.data as + | { main_account?: { username?: string }; support_url?: string | null } + | undefined; + if (code === 'DEVICE_ACCOUNT_LIMIT') { + const username = data?.main_account?.username; + if (username) { + return t('auth.register.device_limit', { username }); + } + return t('auth.register.device_limit_generic'); + } + return err.response?.data?.message || fallbackMessage || t('common.error'); + } + + return fallbackMessage || t('common.error'); + }; + const [discordLinkToken, setDiscordLinkToken] = useState(null); useEffect(() => { @@ -180,7 +202,16 @@ export default function RegisterForm() { }, 1000); } } else { - setError(response.message || t('common.error')); + if (response.error_code === 'DEVICE_ACCOUNT_LIMIT') { + const username = response.data?.main_account?.username; + setError( + username + ? t('auth.register.device_limit', { username }) + : t('auth.register.device_limit_generic'), + ); + } else { + setError(response.message || t('common.error')); + } if (showCaptcha) { setForm((prev) => ({ ...prev, turnstile_token: '' })); @@ -188,8 +219,7 @@ export default function RegisterForm() { } } } catch (err: unknown) { - const error = err as { response?: { data?: { message?: string } } }; - setError(error.response?.data?.message || t('common.error')); + setError(formatRegistrationError(err)); if (showCaptcha) { setForm((prev) => ({ ...prev, turnstile_token: '' })); @@ -215,13 +245,21 @@ export default function RegisterForm() { {error &&
    {error}
    } {success &&
    {success}
    } + +
    - {filteredServers.length > 0 && ( + {servers.length > 0 && (
    + + {t('servers.pagination.page', { + current: String(pagination.current_page), + total: String(pagination.total_pages), + })} + + +
    + )} + {pagination.total_records === 0 ? ( ) : ( <> - {pagination.total_pages > 1 && ( -
    - - - {t('servers.pagination.page', { - current: String(pagination.current_page), - total: String(pagination.total_pages), - })} - - -
    - )}
    - {filteredServers.length === 0 ? ( + {pagination.total_pages > 1 && ( +
    + + + {t('servers.pagination.page', { + current: String(pagination.current_page), + total: String(pagination.total_pages), + })} + + +
    + )} + {pagination.total_records === 0 ? ( ) : ( - <> - {pagination.total_pages > 1 && ( -
    - - - {t('servers.pagination.page', { - current: String(pagination.current_page), - total: String(pagination.total_pages), - })} - - -
    +
    - {filteredServers.map((server) => ( - - assignServerToFolder(server.uuidShort, folderId) - } - onUnassignFolder={() => unassignServer(server.uuidShort)} - showFavoriteToggle - isFavorite={favoriteUuids.includes(server.uuid)} - onToggleFavorite={() => toggleFavorite(server.uuid)} - selectable - selected={selectedServerIds.includes(server.id)} - onToggleSelect={() => toggleServerSelection(server.id)} - /> - ))} -
    - + > + {filteredServers.map((server) => ( + + assignServerToFolder(server.uuidShort, folderId) + } + onUnassignFolder={() => unassignServer(server.uuidShort)} + showFavoriteToggle + isFavorite={favoriteUuids.includes(server.uuid)} + onToggleFavorite={() => toggleFavorite(server.uuid)} + selectable + selected={selectedServerIds.includes(server.id)} + onToggleSelect={() => toggleServerSelection(server.id)} + /> + ))} +
    )} {pagination.total_pages > 1 && ( diff --git a/frontendv2/src/app/(app)/dashboard/tickets/[uuid]/page.tsx b/frontendv2/src/app/(app)/dashboard/tickets/[uuid]/page.tsx index d188a33af..0d3138323 100644 --- a/frontendv2/src/app/(app)/dashboard/tickets/[uuid]/page.tsx +++ b/frontendv2/src/app/(app)/dashboard/tickets/[uuid]/page.tsx @@ -34,6 +34,7 @@ import { } from 'lucide-react'; import { useTranslation } from '@/contexts/TranslationContext'; import { useSession } from '@/contexts/SessionContext'; +import { RoleBadge } from '@/components/RoleBadge'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; @@ -372,18 +373,10 @@ export default function TicketViewPage() { {msg.user?.username || t('tickets.system')} {msg.user?.role && ( - - {msg.user.role.name} - + )}
    )} diff --git a/frontendv2/src/app/(app)/dashboard/tickets/page.tsx b/frontendv2/src/app/(app)/dashboard/tickets/page.tsx index d59bca386..0c3206da1 100644 --- a/frontendv2/src/app/(app)/dashboard/tickets/page.tsx +++ b/frontendv2/src/app/(app)/dashboard/tickets/page.tsx @@ -30,6 +30,8 @@ import {} from '@/components/ui/card'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; +import { useSession } from '@/contexts/SessionContext'; +import Permissions from '@/lib/permissions'; interface Category { id: number; @@ -64,6 +66,11 @@ interface ApiTicket { id: number; name: string; }; + user?: { + uuid: string; + username: string; + email?: string; + }; unread_count?: number; has_unread_messages_since_last_reply?: boolean; } @@ -90,17 +97,25 @@ interface ApiPaginationResponse { to: number; current_page: number; }; + is_admin_view?: boolean; + scope?: string; + open_tickets_count?: number; }; } export default function TicketsPage() { const { t } = useTranslation(); const router = useRouter(); + const { hasPermission } = useSession(); + const canViewAllTickets = hasPermission(Permissions.ADMIN_TICKETS_VIEW); const [loading, setLoading] = useState(true); const [tickets, setTickets] = useState([]); const [categories, setCategories] = useState([]); const [statuses, setStatuses] = useState([]); + const [isAdminView, setIsAdminView] = useState(false); + const [openTicketsCount, setOpenTicketsCount] = useState(0); + const [ticketScope, setTicketScope] = useState<'all_open' | 'mine' | 'all'>('mine'); const [filterStatus, setFilterStatus] = useState('all'); const [filterCategory, setFilterCategory] = useState('all'); @@ -146,6 +161,12 @@ export default function TicketsPage() { fetchFilters(); }, []); + useEffect(() => { + if (canViewAllTickets) { + setTicketScope('all_open'); + } + }, [canViewAllTickets]); + const fetchTickets = useCallback(async () => { setLoading(true); try { @@ -156,11 +177,14 @@ export default function TicketsPage() { if (searchQuery) params.search = searchQuery; if (filterStatus !== 'all') params.status_id = filterStatus; if (filterCategory !== 'all') params.category_id = filterCategory; + if (canViewAllTickets) params.scope = ticketScope; const response = await axios.get('/api/user/tickets', { params }); if (response.data.success) { setTickets(response.data.data.tickets || []); + setIsAdminView(Boolean(response.data.data.is_admin_view)); + setOpenTicketsCount(response.data.data.open_tickets_count ?? 0); const meta = response.data.data.pagination; setPagination((prev) => ({ ...prev, @@ -178,7 +202,15 @@ export default function TicketsPage() { } finally { setLoading(false); } - }, [pagination.page, pagination.pageSize, searchQuery, filterStatus, filterCategory]); + }, [ + pagination.page, + pagination.pageSize, + searchQuery, + filterStatus, + filterCategory, + canViewAllTickets, + ticketScope, + ]); useEffect(() => { void fetchTickets(); @@ -239,7 +271,15 @@ export default function TicketsPage() {

    {t('tickets.title')}

    -

    {t('tickets.viewAndManage')}

    +

    + {isAdminView ? t('tickets.adminViewAndManage') : t('tickets.viewAndManage')} +

    + {isAdminView && openTicketsCount > 0 && ( +
    + + {t('tickets.adminOpenTicketsInline').replace('{count}', String(openTicketsCount))} +
    + )} {unreadTicketsCount > 0 && (
    @@ -257,6 +297,31 @@ export default function TicketsPage() {
    + {canViewAllTickets && ( +
    + {( + [ + ['all_open', t('tickets.adminScopeOpen')], + ['all', t('tickets.adminScopeAll')], + ['mine', t('tickets.adminScopeMine')], + ] as const + ).map(([scope, label]) => ( + + ))} +
    + )} +
    @@ -354,7 +419,13 @@ export default function TicketsPage() { ? 'border-l-red-500 bg-red-500/5' : 'hover:border-l-primary border-l-transparent' }`} - onClick={() => router.push(`/dashboard/tickets/${ticket.uuid}`)} + onClick={() => + router.push( + isAdminView && ticket.user + ? `/admin/tickets/${ticket.uuid}` + : `/dashboard/tickets/${ticket.uuid}`, + ) + } >
    @@ -402,8 +473,14 @@ export default function TicketsPage() { )}
    -
    +
    #{ticket.id} + {isAdminView && ticket.user && ( + <> + + {ticket.user.username} + + )} {ticket.has_unread_messages_since_last_reply && ( <> diff --git a/frontendv2/src/app/(app)/globals.css b/frontendv2/src/app/(app)/globals.css index f38850466..a0770db8b 100644 --- a/frontendv2/src/app/(app)/globals.css +++ b/frontendv2/src/app/(app)/globals.css @@ -259,6 +259,24 @@ html[data-fp-plugin-fullbleed='1'] body [data-fp-plugin-main-inner] { animation: fadeInUp 0.4s ease-out forwards; } +/* Page shell only: opacity fade avoids transform so fixed sidebar/nav stay viewport-bound */ +@keyframes pageShellEnter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-page-shell { + animation: pageShellEnter 0.35s ease-out forwards; +} + +.animate-page-shell-reduced { + animation: pageShellEnter 0.2s ease-out forwards; +} + .animate-scale-in { animation: scaleIn 0.3s ease-out forwards; } diff --git a/frontendv2/src/app/(app)/layout.tsx b/frontendv2/src/app/(app)/layout.tsx index 006c82c38..d843227d3 100644 --- a/frontendv2/src/app/(app)/layout.tsx +++ b/frontendv2/src/app/(app)/layout.tsx @@ -181,106 +181,25 @@ export default async function RootLayout({ children }: { children: React.ReactNo if (savedFont && fontStacks[savedFont]) { document.documentElement.style.setProperty('--app-font-family', fontStacks[savedFont]); } + + // UI preference sync id (keeps theme/layout prefs aligned across tabs). + var syncKey = 'fp:ui:pref:sync'; + var syncCookie = '_fp_ui_sid'; + var syncId = localStorage.getItem(syncKey); + if (!syncId) { + syncId = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function(c){ + return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16); + }).replace(/-/g, ''); + localStorage.setItem(syncKey, syncId); + } + if (document.cookie.indexOf(syncCookie + '=') === -1) { + document.cookie = syncCookie + '=' + syncId + '; path=/; max-age=' + (365*24*60*60) + '; SameSite=Lax'; + } } catch (e) {} })(); `, }} /> -
    + {t('admin.users.edit.potential_alts.user')} + + {t('admin.users.edit.potential_alts.role')} + + {t('admin.users.edit.potential_alts.signals')} + + {t('admin.users.edit.potential_alts.confidence')} + + {t('admin.users.edit.potential_alts.last_seen')} + + {t('admin.users.edit.potential_alts.actions')} +
    + {t('admin.users.edit.potential_alts.empty')} +
    +
    + + + +
    +
    {alt.username}
    +
    + {alt.email} +
    +
    +
    +
    +
    + {alt.role ? ( + + ) : ( + - + )} + {alt.banned === 'true' && ( + + {t('admin.users.badges.banned')} + + )} +
    +
    +
    + {alt.shared_ips.length > 0 && ( +
    + {alt.shared_ips.map((ip) => ( + + {ip} + + ))} +
    + )} + {alt.shared_devices.length > 0 && ( +
    + {alt.shared_devices.map((device) => ( + + {device} + + ))} +
    + )} + {alt.match_reasons.length > 0 && ( +
    + {alt.match_reasons + .map((reason) => + t( + `admin.users.edit.potential_alts.reasons.${reason}`, + { defaultValue: reason }, + ), + ) + .join(' · ')} +
    + )} +
    +
    + + {t( + `admin.users.edit.potential_alts.confidence_${alt.confidence || 'low'}`, + { defaultValue: alt.confidence || 'low' }, + )} + +
    + {alt.match_count}{' '} + {t('admin.users.edit.potential_alts.match_total')} +
    +
    + + {alt.last_seen + ? formatRelativeTime(alt.last_seen, dateOpts) + : '—'} + + + +
    {mail.subject} {mail.status} {mail.created_at} + {formatDateTimeInTz(mail.created_at, dateOpts)} + - +
    + + {mail.status === 'failed' && ( + + )} +