Rôles, tableau de bord et tests unitaires
This commit is contained in:
parent
f508a2fcb5
commit
1799035236
51 changed files with 4350 additions and 0 deletions
19
README.md
Normal file
19
README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# servercontrol
|
||||
|
||||
Interface Symfony pour piloter des serveurs Linux distants en SSH, avec historique des actions. Projet n°4 de la formation DWWM.
|
||||
|
||||
## Stack
|
||||
|
||||
- Symfony 8, PHP 8.4, Doctrine, Twig
|
||||
- PostgreSQL
|
||||
- phpseclib pour les connexions SSH
|
||||
- Rôles administrateur et opérateur
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
composer install
|
||||
cp .env .env.local # DATABASE_URL, APP_SECRET
|
||||
php bin/console doctrine:migrations:migrate
|
||||
symfony serve
|
||||
```
|
||||
25
compose.yaml
Normal file
25
compose.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
image: postgres:${POSTGRES_VERSION:-16}-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||
# You should definitely change the password in production
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-app}
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data:rw
|
||||
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
|
||||
# - ./docker/db/data:/var/lib/postgresql/data:rw
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
volumes:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database_data:
|
||||
###< doctrine/doctrine-bundle ###
|
||||
19
config/bundles.php
Normal file
19
config/bundles.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
11
config/packages/asset_mapper.yaml
Normal file
11
config/packages/asset_mapper.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
framework:
|
||||
asset_mapper:
|
||||
# The paths to make available to the asset mapper.
|
||||
paths:
|
||||
- assets/
|
||||
missing_import_mode: strict
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
asset_mapper:
|
||||
missing_import_mode: warn
|
||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
form:
|
||||
csrf_protection:
|
||||
token_id: submit
|
||||
|
||||
csrf_protection:
|
||||
stateless_token_ids:
|
||||
- submit
|
||||
- authenticate
|
||||
- logout
|
||||
5
config/packages/debug.yaml
Normal file
5
config/packages/debug.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
when@dev:
|
||||
debug:
|
||||
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
|
||||
# See the "server:dump" command to start a new server.
|
||||
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"
|
||||
46
config/packages/doctrine.yaml
Normal file
46
config/packages/doctrine.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
identity_generation_preferences:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
15
config/packages/framework.yaml
Normal file
15
config/packages/framework.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
3
config/packages/mailer.yaml
Normal file
3
config/packages/mailer.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
26
config/packages/messenger.yaml
Normal file
26
config/packages/messenger.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
buses:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
55
config/packages/monolog.yaml
Normal file
55
config/packages/monolog.yaml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
monolog:
|
||||
channels:
|
||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!event"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
12
config/packages/notifier.yaml
Normal file
12
config/packages/notifier.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
framework:
|
||||
notifier:
|
||||
chatter_transports:
|
||||
texter_transports:
|
||||
channel_policy:
|
||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||
urgent: ['email']
|
||||
high: ['email']
|
||||
medium: ['email']
|
||||
low: ['email']
|
||||
admin_recipients:
|
||||
- { email: admin@example.com }
|
||||
3
config/packages/property_info.yaml
Normal file
3
config/packages/property_info.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
5
config/packages/reset_password.yaml
Normal file
5
config/packages/reset_password.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
symfonycasts_reset_password:
|
||||
# Replace symfonycasts.reset_password.fake_request_repository with the full
|
||||
# namespace of the password reset request repository after it has been created.
|
||||
# i.e. App\Repository\ResetPasswordRequestRepository
|
||||
request_password_repository: symfonycasts.reset_password.fake_request_repository
|
||||
10
config/packages/routing.yaml
Normal file
10
config/packages/routing.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
framework:
|
||||
router:
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
default_uri: '%env(DEFAULT_URI)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
55
config/packages/security.yaml
Normal file
55
config/packages/security.yaml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
security:
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: email
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
form_login:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
default_target_path: /
|
||||
logout:
|
||||
path: app_logout
|
||||
target: app_login
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
access_denied_handler: App\Security\AccessDeniedHandler
|
||||
|
||||
access_control:
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/logout, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/forgot-password, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/reset-password, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/profile, roles: ROLE_OPERATOR }
|
||||
- { path: ^/pending, roles: ROLE_USER }
|
||||
- { path: ^/$, roles: ROLE_USER }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/, roles: ROLE_OPERATOR }
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_OPERATOR: ROLE_USER
|
||||
ROLE_ADMIN: ROLE_OPERATOR
|
||||
|
||||
when@test:
|
||||
security:
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4
|
||||
time_cost: 3
|
||||
memory_cost: 10
|
||||
5
config/packages/translation.yaml
Normal file
5
config/packages/translation.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
4
config/packages/ux_turbo.yaml
Normal file
4
config/packages/ux_turbo.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
csrf_protection:
|
||||
check_header: true
|
||||
11
config/packages/validator.yaml
Normal file
11
config/packages/validator.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
framework:
|
||||
validation:
|
||||
# Enables validator auto-mapping support.
|
||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||
#auto_mapping:
|
||||
# App\Entity\: []
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
validation:
|
||||
not_compromised_password: false
|
||||
13
config/packages/web_profiler.yaml
Normal file
13
config/packages/web_profiler.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
when@dev:
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
profiler:
|
||||
collect: false
|
||||
collect_serializer_data: true
|
||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
1652
config/reference.php
Normal file
1652
config/reference.php
Normal file
File diff suppressed because it is too large
Load diff
11
config/routes.yaml
Normal file
11
config/routes.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||
|
||||
# This file is the entry point to configure the routes of your app.
|
||||
# Methods with the #[Route] attribute are automatically imported.
|
||||
# See also https://symfony.com/doc/current/routing.html
|
||||
|
||||
# To list all registered routes, run the following command:
|
||||
# bin/console debug:router
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
3
config/routes/security.yaml
Normal file
3
config/routes/security.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
_security_logout:
|
||||
resource: security.route_loader.logout
|
||||
type: service
|
||||
8
config/routes/web_profiler.yaml
Normal file
8
config/routes/web_profiler.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
when@dev:
|
||||
web_profiler_wdt:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||
prefix: /_wdt
|
||||
|
||||
web_profiler_profiler:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||
prefix: /_profiler
|
||||
23
config/services.yaml
Normal file
23
config/services.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
28
importmap.php
Normal file
28
importmap.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Returns the importmap for this application.
|
||||
*
|
||||
* - "path" is a path inside the asset mapper system. Use the
|
||||
* "debug:asset-map" command to see the full list of paths.
|
||||
*
|
||||
* - "entrypoint" (JavaScript only) set to true for any module that will
|
||||
* be used as an "entrypoint" (and passed to the importmap() Twig function).
|
||||
*
|
||||
* The "importmap:require" command can be used to add new entries to this file.
|
||||
*/
|
||||
return [
|
||||
'app' => [
|
||||
'path' => './assets/app.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
'@hotwired/stimulus' => [
|
||||
'version' => '3.2.2',
|
||||
],
|
||||
'@symfony/stimulus-bundle' => [
|
||||
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
|
||||
],
|
||||
'@hotwired/turbo' => [
|
||||
'version' => '7.3.0',
|
||||
],
|
||||
];
|
||||
62
public/.htaccess
Normal file
62
public/.htaccess
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# For a symfony application to work properly, you MUST store this .htaccess in
|
||||
# the same directory as your front controller, index.php, in a standard symfony
|
||||
# web application is under the "public" project subdirectory.
|
||||
|
||||
# Use the front controller as index file.
|
||||
DirectoryIndex index.php
|
||||
|
||||
# Uncomment the following line if you install assets as symlinks or if you
|
||||
# experience problems related to symlinks when compiling LESS/Sass/CoffeScript.
|
||||
# Options +FollowSymlinks
|
||||
|
||||
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
|
||||
# to the front controller "/index.php" but be rewritten to "/index.php/index".
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# This RewriteRule is used to dynamically discover the RewriteBase path.
|
||||
# See https://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewriterule
|
||||
# Here we will compare the stripped per-dir path *relative to the filesystem
|
||||
# path where the .htaccess file is read from* with the URI of the request.
|
||||
#
|
||||
# If a match is found, the prefix path is stored into an ENV var that is later
|
||||
# used to properly prefix the URI of the front controller index.php.
|
||||
# This is what makes it possible to host a Symfony application under a subpath,
|
||||
# such as example.com/subpath
|
||||
|
||||
# The convoluted rewrite condition means:
|
||||
# 1. Matches the entire URI in the RewriteRule and backreference it using $0
|
||||
# 2. Strips the request uri the per-directory path and use it as REQUEST_URI.
|
||||
# This is documented in https://bit.ly/3zDm3SI ("What is matched?")
|
||||
# 3. Evaluates the RewriteCond. Assuming that your DocumentRoot is /var/www/html,
|
||||
# that this .htaccess is in the /var/www/html/public directory and your request URI
|
||||
# is /public/hello/world, the following happens:
|
||||
#
|
||||
# * strips per-dir prefix: /var/www/html/public/hello/world -> hello/world
|
||||
# * matches the pattern '.*' with the string 'hello/world' (the second capture group in the PCRE pattern)
|
||||
# * matches the pattern '/.+/ with the string '/public' (the first capture group in the PCRE pattern)
|
||||
# * RewriteCond: input='/public/hello/world::hello/world' pattern='^(/.+)/(.*)::\\2$' => matched
|
||||
#
|
||||
# 4. Execute the RewriteRule:
|
||||
# * The %1 in the RewriteRule flag E=BASE:%1 refers to the first capture group captured in the RewriteCond ^(/.+)/(.*)
|
||||
# * sets the ENV variable BASE to '/public'
|
||||
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
|
||||
RewriteRule .* - [E=BASE:%1]
|
||||
|
||||
# Sets the HTTP_AUTHORIZATION header removed by Apache
|
||||
RewriteCond %{HTTP:Authorization} .+
|
||||
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
|
||||
|
||||
# Removes the /index.php/ part from a URL, if present
|
||||
RewriteCond %{ENV:REDIRECT_STATUS} =""
|
||||
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
|
||||
|
||||
# If the requested filename exists, simply serve it.
|
||||
# Otherwise rewrite all other queries to the front controller.
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
||||
</IfModule>
|
||||
9
public/index.php
Normal file
9
public/index.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return static function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
322
templates/admin/edit_user.html.twig
Normal file
322
templates/admin/edit_user.html.twig
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit {{ user.pseudo }} - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row g-4 justify-content-center">
|
||||
<div class="col-lg-4 col-md-5">
|
||||
|
||||
<a href="{{ path('app_admin_users') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to users
|
||||
</a>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-3">
|
||||
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width:44px;height:44px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill text-white fs-5"></i>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<div class="fw-bold text-truncate">
|
||||
{{ user.pseudo }}
|
||||
{% if isSelf %}
|
||||
<span class="badge bg-secondary ms-1" style="font-size:.65rem;">Vous</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small text-truncate">{{ user.email }}</div>
|
||||
</div>
|
||||
<span class="badge ms-auto {{ user.isActive ? 'bg-success' : 'bg-secondary' }}">
|
||||
{{ user.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# ── Onglets ── #}
|
||||
<div class="card-body p-0">
|
||||
<ul class="nav nav-tabs px-3 pt-3" id="userEditTabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link {% if activeTab == 'roles' %}active{% endif %}"
|
||||
data-bs-toggle="tab" data-bs-target="#tab-roles" type="button">
|
||||
<i class="bi bi-shield me-1"></i>Rôles
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link {% if activeTab == 'email' %}active{% endif %}"
|
||||
data-bs-toggle="tab" data-bs-target="#tab-email" type="button">
|
||||
<i class="bi bi-envelope me-1"></i>Email
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link {% if activeTab == 'password' %}active{% endif %}"
|
||||
data-bs-toggle="tab" data-bs-target="#tab-password" type="button">
|
||||
<i class="bi bi-key me-1"></i>Mot de passe
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content p-4">
|
||||
|
||||
{# ── Onglet 1 : Rôles & Statut ── #}
|
||||
<div class="tab-pane fade {% if activeTab == 'roles' %}show active{% endif %}" id="tab-roles">
|
||||
|
||||
{% if isSelf %}
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Vous ne pouvez pas modifier vos propres rôles et statut.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Roles</label>
|
||||
<div class="d-flex flex-wrap gap-2 p-3 rounded border {{ isSelf ? 'opacity-50' : '' }}">
|
||||
{% for child in form.roles %}
|
||||
<div class="form-check form-check-inline m-0">
|
||||
{{ form_widget(child, {attr: {
|
||||
class: 'form-check-input',
|
||||
disabled: isSelf ? 'disabled' : false
|
||||
}}) }}
|
||||
{{ form_label(child, null, {label_attr: {class: 'form-check-label'}}) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ form_errors(form.roles) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 {{ isSelf ? 'opacity-50' : '' }}">
|
||||
<div class="form-check form-switch">
|
||||
{{ form_widget(form.isActive, {attr: {
|
||||
class: 'form-check-input',
|
||||
role: 'switch',
|
||||
id: 'isActiveSwitch',
|
||||
disabled: isSelf ? 'disabled' : false
|
||||
}}) }}
|
||||
<label class="form-check-label fw-semibold" for="isActiveSwitch">
|
||||
Compte actif
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not isSelf %}
|
||||
<div class="d-flex gap-2 pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-floppy me-1"></i>Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
|
||||
{# ── Onglet 2 : Email ── #}
|
||||
<div class="tab-pane fade {% if activeTab == 'email' %}show active{% endif %}" id="tab-email">
|
||||
|
||||
<p class="text-muted small mb-3">
|
||||
Modifier l'adresse email du compte. L'utilisateur devra utiliser
|
||||
la nouvelle adresse pour se connecter.
|
||||
</p>
|
||||
|
||||
{{ form_start(emailForm) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(emailForm.email, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(emailForm.email, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(emailForm.email) }}
|
||||
</div>
|
||||
<div class="pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-floppy me-1"></i>Changer l'email
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(emailForm) }}
|
||||
</div>
|
||||
|
||||
{# ── Onglet 3 : Mot de passe ── #}
|
||||
<div class="tab-pane fade {% if activeTab == 'password' %}show active{% endif %}" id="tab-password">
|
||||
|
||||
<p class="text-muted small mb-3">
|
||||
Definir un nouveau mot de passe pour ce compte.
|
||||
{% if isSelf %}L'ancien mot de passe sera remplacé.{% endif %}
|
||||
</p>
|
||||
|
||||
{{ form_start(passwordForm) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(passwordForm.newPassword.first, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(passwordForm.newPassword.first, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(passwordForm.newPassword.first) }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{{ form_label(passwordForm.newPassword.second, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(passwordForm.newPassword.second, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(passwordForm.newPassword.second) }}
|
||||
{{ form_errors(passwordForm.newPassword) }}
|
||||
</div>
|
||||
<div class="pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-key me-1"></i>Changer le mot de passe
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(passwordForm) }}
|
||||
</div>
|
||||
|
||||
</div>{# /tab-content #}
|
||||
</div>{# /card-body #}
|
||||
|
||||
{% if accesses|length > 0 %}
|
||||
<div class="card-footer">
|
||||
<div class="text-muted small mb-1 fw-semibold">
|
||||
<i class="bi bi-hdd-stack me-1"></i>Assigned servers ({{ accesses|length }})
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for access in accesses %}
|
||||
<span class="badge bg-primary">{{ access.server.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-7">
|
||||
<div style="margin-top: 2rem;">
|
||||
|
||||
{% if accesses|length > 0 %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong><i class="bi bi-check-circle me-2 text-success"></i>Current access ({{ accesses|length }})</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<th>IP</th>
|
||||
<th>SSH User</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for access in accesses %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ access.server.name }}</td>
|
||||
<td><code class="small">{{ access.server.ipAddress }}</code></td>
|
||||
<td><code class="small">{{ access.sshUser }}</code></td>
|
||||
<td>
|
||||
<form method="post"
|
||||
action="{{ path('app_admin_revoke_access', {userId: user.id, accessId: access.id}) }}"
|
||||
onsubmit="return confirm('Revoke access to {{ access.server.name }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('revoke_access_' ~ access.id) }}">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Revoke">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set unassignedServers = allServers|filter(s => s.id not in assignedIds) %}
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<strong><i class="bi bi-plus-circle me-2 text-primary"></i>Grant server access</strong>
|
||||
<span class="badge bg-secondary">{{ unassignedServers|length }} available</span>
|
||||
</div>
|
||||
<div class="card-body pb-0">
|
||||
{% if unassignedServers|length == 0 %}
|
||||
<p class="text-muted small pb-3 mb-0">
|
||||
<i class="bi bi-check-all me-1"></i>Tout les serveurs sont déjà assignés a cet utilisateur.
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="server-search-wrap mb-3">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="text" id="server-search" class="form-control form-control-sm"
|
||||
placeholder="Search servers by name or IP…">
|
||||
</div>
|
||||
|
||||
<div id="server-list">
|
||||
{% for srv in unassignedServers %}
|
||||
<div class="server-item border rounded mb-2 overflow-hidden"
|
||||
data-name="{{ srv.name|lower }}" data-ip="{{ srv.ipAddress }}">
|
||||
|
||||
<div class="d-flex align-items-center px-3 py-2 gap-2"
|
||||
style="cursor:pointer" onclick="toggleServer(this)">
|
||||
<i class="bi bi-hdd text-primary"></i>
|
||||
<div class="flex-grow-1">
|
||||
<span class="fw-semibold">{{ srv.name }}</span>
|
||||
<code class="text-muted small ms-2">{{ srv.ipAddress }}</code>
|
||||
</div>
|
||||
<span class="badge {{ srv.lastStatus == 'online' ? 'bg-success' : (srv.lastStatus == 'offline' ? 'bg-danger' : 'bg-secondary') }} small">
|
||||
{{ srv.lastStatus }}
|
||||
</span>
|
||||
<i class="bi bi-chevron-down text-muted small toggle-icon"></i>
|
||||
</div>
|
||||
|
||||
<form class="grant-form" method="post"
|
||||
action="{{ path('app_admin_grant_access', {id: user.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('grant_access_' ~ user.id) }}">
|
||||
<input type="hidden" name="server_id" value="{{ srv.id }}">
|
||||
<div style="min-width:140px;flex:1">
|
||||
<label class="form-label">SSH User</label>
|
||||
<input class="form-control form-control-sm" name="ssh_user"
|
||||
placeholder="e.g. deploy" required>
|
||||
</div>
|
||||
<div style="min-width:160px;flex:1">
|
||||
<label class="form-label">SSH Password</label>
|
||||
<input class="form-control form-control-sm" name="ssh_password"
|
||||
type="password" placeholder="••••••••" required>
|
||||
</div>
|
||||
<div class="d-flex gap-1 align-self-end">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-3">
|
||||
<i class="bi bi-check-lg me-1"></i>Grant
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="toggleServer(this.closest('.server-item').querySelector('[onclick]'))">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p id="no-results" class="text-muted small d-none px-1 pb-3">
|
||||
<i class="bi bi-search me-1"></i>Aucun serveur ne correspond a votre recheche.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function toggleServer(trigger) {
|
||||
const item = trigger.closest('.server-item');
|
||||
const isOpen = item.classList.contains('open');
|
||||
document.querySelectorAll('.server-item.open').forEach(el => {
|
||||
el.classList.remove('open');
|
||||
el.querySelector('.toggle-icon').style.transform = '';
|
||||
});
|
||||
if (!isOpen) {
|
||||
item.classList.add('open');
|
||||
item.querySelector('.toggle-icon').style.transform = 'rotate(180deg)';
|
||||
setTimeout(() => item.querySelector('input[name="ssh_user"]')?.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('server-search')?.addEventListener('input', function () {
|
||||
const q = this.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('.server-item').forEach(item => {
|
||||
const match = !q || item.dataset.name.includes(q) || item.dataset.ip.includes(q);
|
||||
item.classList.toggle('hidden', !match);
|
||||
if (match) visible++;
|
||||
});
|
||||
document.getElementById('no-results').classList.toggle('d-none', visible > 0);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
53
templates/admin/users.html.twig
Normal file
53
templates/admin/users.html.twig
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Users - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1 class="h3 mb-4">User Management</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Pseudo</th><th>Email</th><th>Roles</th><th>Status</th><th>Registered</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{{ not user.active ? 'table-secondary text-muted' : '' }}">
|
||||
<td>{{ user.pseudo }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% for role in user.roles %}
|
||||
{% if role != 'ROLE_USER' %}
|
||||
<span class="badge bg-{{ role == 'ROLE_ADMIN' ? 'danger' : 'primary' }}">{{ role }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ user.active ? 'success' : 'secondary' }}">
|
||||
{{ user.active ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.createdAt|date('d/m/Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ path('app_admin_user_edit', {id: user.id}) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if user != app.user %}
|
||||
<form method="post" action="{{ path('app_admin_user_toggle', {id: user.id}) }}" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle_user_' ~ user.id) }}">
|
||||
<button class="btn btn-sm btn-outline-{{ user.active ? 'warning' : 'success' }}"
|
||||
title="{{ user.active ? 'Disable' : 'Enable' }}">
|
||||
<i class="bi bi-{{ user.active ? 'person-x' : 'person-check' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
423
templates/base.html.twig
Normal file
423
templates/base.html.twig
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" data-bs-theme="dark">
|
||||
<head>
|
||||
<link rel="icon" type="image/svg+xml" data-favicon="yb" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22%3E%3Ctitle%3EServerControl%3C/title%3E%3Crect width=%2264%22 height=%2264%22 rx=%2214%22 fill=%22#0d6efd%22/%3E%3Cg fill=%22#fff%22%3E%3Crect x=%2213%22 y=%2212%22 width=%2238%22 height=%2211%22 rx=%223.5%22/%3E%3Crect x=%2213%22 y=%2226.5%22 width=%2238%22 height=%2211%22 rx=%223.5%22/%3E%3Crect x=%2213%22 y=%2241%22 width=%2238%22 height=%2211%22 rx=%223.5%22/%3E%3C/g%3E%3Cg fill=%22#0d6efd%22%3E%3Ccircle cx=%2219.5%22 cy=%2217.5%22 r=%222%22/%3E%3Ccircle cx=%2219.5%22 cy=%2232%22 r=%222%22/%3E%3Ccircle cx=%2219.5%22 cy=%2246.5%22 r=%222%22/%3E%3Crect x=%2234%22 y=%2216%22 width=%2212%22 height=%223%22 rx=%221.5%22/%3E%3Crect x=%2234%22 y=%2230.5%22 width=%2212%22 height=%223%22 rx=%221.5%22/%3E%3C/g%3E%3Ccircle cx=%2240%22 cy=%2246.5%22 r=%222.4%22 fill=%22#22c55e%22/%3E%3Ccircle cx=%2246%22 cy=%2246.5%22 r=%222.4%22 fill=%22#0d6efd%22/%3E%3C/svg%3E">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}ServerControl{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--text: #e8ede8;
|
||||
--background: #0b0f0c;
|
||||
--primary: #b3c7b4;
|
||||
--secondary: #4b435b;
|
||||
--accent: #997595;
|
||||
--sidebar-bg: #0d1210;
|
||||
--card-bg: #111711;
|
||||
--border: #1e2820;
|
||||
--muted: #56695a;
|
||||
--input-bg: #0d1210;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--text: #121712;
|
||||
--background: #f0f4f1;
|
||||
--primary: #384c39;
|
||||
--secondary: #aca4bc;
|
||||
--accent: #8a6686;
|
||||
--sidebar-bg: #e7ece8;
|
||||
--card-bg: #ffffff;
|
||||
--border: #d2dad3;
|
||||
--muted: #7a8c7b;
|
||||
--input-bg: #ffffff;
|
||||
}
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
--text: #e8ede8;
|
||||
--background: #0b0f0c;
|
||||
--primary: #b3c7b4;
|
||||
--secondary: #4b435b;
|
||||
--accent: #997595;
|
||||
--sidebar-bg: #0d1210;
|
||||
--card-bg: #111711;
|
||||
--border: #1e2820;
|
||||
--muted: #56695a;
|
||||
--input-bg: #0d1210;
|
||||
}
|
||||
[data-bs-theme="light"] {
|
||||
--text: #121712;
|
||||
--background: #f0f4f1;
|
||||
--primary: #384c39;
|
||||
--secondary: #aca4bc;
|
||||
--accent: #8a6686;
|
||||
--sidebar-bg: #e7ece8;
|
||||
--card-bg: #ffffff;
|
||||
--border: #d2dad3;
|
||||
--muted: #7a8c7b;
|
||||
--input-bg: #ffffff;
|
||||
}
|
||||
|
||||
body { background: var(--background); color: var(--text); }
|
||||
|
||||
.app-wrapper { display: flex; min-height: 100vh; }
|
||||
.main-column { flex: 1; display: flex; flex-direction: column; min-height: 100vh; overflow-x: hidden; }
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.sidebar .brand {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: .75rem;
|
||||
padding: .35rem .65rem;
|
||||
border-radius: 6px;
|
||||
color: var(--primary);
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.sidebar-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: .1rem .5rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: .35rem;
|
||||
}
|
||||
.user-name {
|
||||
font-size: .82rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
}
|
||||
.theme-switch-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
font-size: .68rem;
|
||||
}
|
||||
.theme-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
#theme-checkbox {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.theme-track {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border-radius: 20px;
|
||||
background: var(--muted);
|
||||
position: relative;
|
||||
transition: background .22s ease-in-out;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.theme-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.35);
|
||||
transition: left .22s ease-in-out, transform .22s ease-in-out;
|
||||
}
|
||||
#theme-checkbox:checked + .theme-track { background: var(--accent); }
|
||||
#theme-checkbox:checked + .theme-track::after {
|
||||
left: calc(100% - 3px);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.theme-icon-sun { color: #c8a83a; }
|
||||
.theme-icon-moon { color: var(--muted); }
|
||||
|
||||
.btn-logout {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: .82rem;
|
||||
color: var(--muted);
|
||||
transition: background .12s, color .12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-logout:hover { background: rgba(220,60,60,.1); color: #e05a5a; }
|
||||
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
border-radius: 6px;
|
||||
padding: .45rem .75rem;
|
||||
font-size: .87rem;
|
||||
color: var(--muted);
|
||||
transition: background .12s, color .12s;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sidebar .nav-link:hover { color: var(--primary); background: rgba(179,199,180,.07); }
|
||||
.sidebar .nav-link.active {
|
||||
color: var(--accent);
|
||||
background: rgba(153,117,149,.13);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-section-label {
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
padding: .3rem .75rem .1rem;
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card { background: var(--card-bg) !important; border-color: var(--border) !important; box-shadow: 0 2px 8px rgba(0,0,0,.2); }
|
||||
.card-header { background: rgba(0,0,0,.15) !important; border-bottom-color: var(--border) !important; color: var(--primary); }
|
||||
.card-footer { background: rgba(0,0,0,.1) !important; border-top-color: var(--border) !important; }
|
||||
[data-bs-theme="light"] .card-header { background: rgba(0,0,0,.03) !important; }
|
||||
[data-bs-theme="light"] .card-footer { background: rgba(0,0,0,.02) !important; }
|
||||
|
||||
thead th { border-bottom: 2px solid var(--border); font-size: .82rem; color: var(--muted); }
|
||||
.table { color: var(--text); }
|
||||
.table td, .table th { border-color: var(--border); }
|
||||
[data-bs-theme="dark"] .table-light,
|
||||
[data-bs-theme="dark"] .table-light th,
|
||||
[data-bs-theme="dark"] .table-light td { color: #212529 !important; }
|
||||
[data-bs-theme="light"] .text-white-50 { color: rgba(0,0,0,.45) !important; }
|
||||
|
||||
.form-control, .form-select { background-color: var(--input-bg); border-color: var(--border); color: var(--text); }
|
||||
.form-control:focus, .form-select:focus {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(153,117,149,.2);
|
||||
color: var(--text);
|
||||
}
|
||||
.input-group-text { background: var(--input-bg); border-color: var(--border); color: var(--muted); }
|
||||
.form-section-title {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .75rem;
|
||||
padding-bottom: .4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field-icon { color: var(--muted); }
|
||||
|
||||
.modal-content { background: var(--card-bg); border-color: var(--border); color: var(--text); }
|
||||
.modal-header, .modal-footer { border-color: var(--border); }
|
||||
|
||||
.status-online { color: #4caf78; }
|
||||
.status-offline { color: #e05a5a; }
|
||||
.status-unknown { color: var(--muted); }
|
||||
|
||||
[data-bs-theme="dark"] .btn-close { filter: invert(1); }
|
||||
|
||||
.terminal-titlebar {
|
||||
background: #161b1a;
|
||||
padding: 7px 14px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid #21312a;
|
||||
}
|
||||
.terminal-host { color: var(--primary); font-size: .78rem; font-family: 'Courier New', monospace; }
|
||||
.terminal-body {
|
||||
padding: 12px 14px;
|
||||
height: 380px;
|
||||
overflow-y: auto;
|
||||
color: #c9d9c9;
|
||||
background: #080e09;
|
||||
font-size: .82rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.terminal-body::-webkit-scrollbar { width: 5px; }
|
||||
.terminal-body::-webkit-scrollbar-thumb { background: #2a3a2d; border-radius: 3px; }
|
||||
.term-line { white-space: pre-wrap; word-break: break-all; }
|
||||
.term-prompt { color: #7ab87a; }
|
||||
.term-success { color: #56d364; }
|
||||
.term-error { color: #e06060; }
|
||||
.term-info { color: #7a9e8a; font-style: italic; }
|
||||
.term-warn { color: #c8a83a; }
|
||||
.terminal-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #080e09;
|
||||
border-top: 1px solid #1a2a1d;
|
||||
padding: 8px 14px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.terminal-ps1 { color: #7ab87a; font-family: 'Courier New', monospace; font-size: .82rem; white-space: nowrap; margin-right: 6px; }
|
||||
.terminal-cmd {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #c9d9c9;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: .82rem;
|
||||
caret-color: #7ab87a;
|
||||
}
|
||||
.terminal-cmd::placeholder { color: #2e4030; }
|
||||
#terminal-card { display: flex; flex-direction: column; }
|
||||
#terminal-card .card-body { display: flex; flex-direction: column; background: #080e09 !important; padding: 0 !important; }
|
||||
|
||||
.service-card { border: 1px solid var(--border) !important; box-shadow: 0 1px 3px rgba(0,0,0,.15) !important; }
|
||||
.service-card .card-footer { background: transparent !important; border-top: 1px solid var(--border) !important; }
|
||||
|
||||
.role-badge { font-size: .75rem; padding: .3em .6em; }
|
||||
|
||||
.server-search-wrap { position: relative; }
|
||||
.server-search-wrap .bi-search { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
|
||||
#server-search { padding-left: 2rem; }
|
||||
#server-list { max-height: 340px; overflow-y: auto; }
|
||||
#server-list::-webkit-scrollbar { width: 5px; }
|
||||
#server-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
.server-item { transition: background .12s; }
|
||||
.server-item.hidden { display: none !important; }
|
||||
.grant-form {
|
||||
display: none;
|
||||
background: rgba(153,117,149,.05);
|
||||
border-top: 1px solid rgba(153,117,149,.15);
|
||||
padding: .75rem 1rem;
|
||||
gap: .5rem;
|
||||
}
|
||||
.server-item.open .grant-form { display: flex; flex-wrap: wrap; align-items: flex-end; }
|
||||
.grant-form .form-label { font-size: .78rem; font-weight: 600; margin-bottom: .2rem; }
|
||||
.grant-form .form-control { font-size: .82rem; }
|
||||
</style>
|
||||
{% block stylesheets %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-wrapper">
|
||||
{% if app.user %}
|
||||
<nav class="sidebar">
|
||||
<a class="brand" href="{{ path('app_server_index') }}">
|
||||
<i class="bi bi-server me-2"></i>ServerControl
|
||||
</a>
|
||||
<div class="sidebar-top">
|
||||
<span class="user-name">{{ app.user.pseudo }}</span>
|
||||
<div class="sidebar-actions">
|
||||
<div class="theme-switch-wrap" title="Changer de thème">
|
||||
<i class="bi bi-sun-fill theme-icon-sun"></i>
|
||||
<label class="theme-switch">
|
||||
<input type="checkbox" id="theme-checkbox" onchange="toggleTheme()">
|
||||
<span class="theme-track"></span>
|
||||
</label>
|
||||
<i class="bi bi-moon-stars-fill theme-icon-moon"></i>
|
||||
</div>
|
||||
<a href="{{ path('app_logout') }}" class="btn-logout" title="Déconnexion">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-divider"></div>
|
||||
<ul class="nav flex-column gap-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if app.request.attributes.get('_route') starts with 'app_server' %}active{% endif %}"
|
||||
href="{{ path('app_server_index') }}">
|
||||
<i class="bi bi-hdd-stack me-2"></i>Servers
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if app.request.attributes.get('_route') starts with 'app_log' %}active{% endif %}"
|
||||
href="{{ path('app_log_index') }}">
|
||||
<i class="bi bi-journal-text me-2"></i>Logs
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if app.request.attributes.get('_route') == 'app_profile' %}active{% endif %}"
|
||||
href="{{ path('app_profile') }}">
|
||||
<i class="bi bi-person-gear me-2"></i>Mon profil
|
||||
</a>
|
||||
</li>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li class="nav-item mt-2">
|
||||
<span class="sidebar-section-label">Admin</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if app.request.attributes.get('_route') starts with 'app_admin' %}active{% endif %}"
|
||||
href="{{ path('app_admin_users') }}">
|
||||
<i class="bi bi-people me-2"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('app_server_new') }}">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Server
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="main-column">
|
||||
<main class="p-4 flex-grow-1">
|
||||
{% for label, messages in app.flashes %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ label == 'error' ? 'danger' : label }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const saved = localStorage.getItem('sc-theme') || 'dark';
|
||||
applyTheme(saved);
|
||||
})();
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
localStorage.setItem('sc-theme', theme);
|
||||
const cb = document.getElementById('theme-checkbox');
|
||||
if (cb) cb.checked = (theme === 'dark');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-bs-theme');
|
||||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
</script>
|
||||
{% block javascripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
10
templates/bundles/TwigBundle/Exception/error403.html.twig
Normal file
10
templates/bundles/TwigBundle/Exception/error403.html.twig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}403 - Access Denied{% endblock %}
|
||||
{% block body %}
|
||||
<div class="text-center mt-5">
|
||||
<h1 class="display-1 text-danger">403</h1>
|
||||
<h2>Access Denied</h2>
|
||||
<p class="text-muted">You don't have permission to access this page.</p>
|
||||
<a href="{{ path('app_server_index') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal file
10
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}404 - Not Found{% endblock %}
|
||||
{% block body %}
|
||||
<div class="text-center mt-5">
|
||||
<h1 class="display-1 text-warning">404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p class="text-muted">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ path('app_server_index') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
templates/bundles/TwigBundle/Exception/error500.html.twig
Normal file
10
templates/bundles/TwigBundle/Exception/error500.html.twig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}500 - Server Error{% endblock %}
|
||||
{% block body %}
|
||||
<div class="text-center mt-5">
|
||||
<h1 class="display-1 text-danger">500</h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p class="text-muted">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="{{ path('app_server_index') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
templates/emails/reset_password.html.twig
Normal file
44
templates/emails/reset_password.html.twig
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f0f4f1; margin: 0; padding: 20px; }
|
||||
.container { max-width: 560px; margin: 0 auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||
.header { background: #1a2a1e; padding: 24px 32px; }
|
||||
.header h1 { color: #b3c7b4; margin: 0; font-size: 1.2rem; }
|
||||
.body { padding: 32px; color: #333; }
|
||||
.body p { line-height: 1.6; margin: 0 0 16px; }
|
||||
.btn { display: inline-block; padding: 12px 28px; background: #4b5e4c; color: #ffffff !important; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 8px 0 16px; }
|
||||
.url-fallback { font-size: .8rem; color: #888; word-break: break-all; }
|
||||
.footer { padding: 16px 32px; background: #f7f9f7; border-top: 1px solid #e0e8e1; font-size: .78rem; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🖥 ServerControl — Reinitialisation de mot de passe</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Bonjour <strong>{{ user.pseudo }}</strong>,</p>
|
||||
<p>Vous avez demandé a reinitialiser votre mot de passe sur ServerControl.
|
||||
Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||
|
||||
<p><a href="{{ resetUrl }}" class="btn">Reinitialiser mon mot de passe</a></p>
|
||||
|
||||
<p>Ce lien est valable pendant <strong>1 heure</strong>. Apres ce delai il ne fonctionnera plus
|
||||
et vous devrez faire une nouvelle demande.</p>
|
||||
|
||||
<p>Si vous n'avez pas demandé cette reinitialisation, ignorez simplement cet email.
|
||||
Votre mot de passe actuel reste inchangé.</p>
|
||||
|
||||
<p class="url-fallback">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||
{{ resetUrl }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Cet email a été envoyé automatiquement, merci de ne pas y repondre.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
83
templates/logs/index.html.twig
Normal file
83
templates/logs/index.html.twig
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Logs - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1 class="h3 mb-4">Action Logs</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="server">
|
||||
<option value="">All servers</option>
|
||||
{% for server in servers %}
|
||||
<option value="{{ server.id }}" {% if filters.serverId == server.id %}selected{% endif %}>
|
||||
{{ server.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="user">
|
||||
<option value="">All users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if filters.userId == user.id %}selected{% endif %}>
|
||||
{{ user.pseudo }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-2">
|
||||
<input type="date" class="form-control" name="date_from" value="{{ filters.dateFrom ?? '' }}" placeholder="From">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" class="form-control" name="date_to" value="{{ filters.dateTo ?? '' }}" placeholder="To">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-secondary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if logs|length == 0 %}
|
||||
<p class="text-muted p-3">No logs found.</p>
|
||||
{% else %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Date</th><th>Server</th><th>Action</th><th>Status</th><th>User</th><th>Output</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.executedAt|date('d/m/Y H:i:s') }}</td>
|
||||
<td>{{ log.server.name }}</td>
|
||||
<td><code>{{ log.actionType }}</code></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ log.status == 'success' ? 'success' : 'danger' }}">{{ log.status }}</span>
|
||||
</td>
|
||||
<td>{{ log.user ? log.user.pseudo : '<em>deleted</em>'|raw }}</td>
|
||||
<td>
|
||||
{% if log.output %}
|
||||
<button class="btn btn-sm btn-link p-0" data-bs-toggle="collapse"
|
||||
data-bs-target="#output-{{ log.id }}">View</button>
|
||||
<div class="collapse" id="output-{{ log.id }}">
|
||||
<pre class="bg-dark text-white p-2 mt-1 rounded small">{{ log.output }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-3 d-flex justify-content-center">
|
||||
{{ knp_pagination_render(logs) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
templates/security/forgot_password.html.twig
Normal file
32
templates/security/forgot_password.html.twig
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Mot de passe oublié - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h2 class="text-center mb-2"><i class="bi bi-key me-2"></i>Mot de passe oublié</h2>
|
||||
<p class="text-center mb-4" style="font-size:.85rem; color:var(--muted);">
|
||||
Saisissez votre adresse email et on vous envoie un lien pour réinitialiser votre mot de passe.
|
||||
</p>
|
||||
|
||||
{{ form_start(form) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.email) }}
|
||||
{{ form_widget(form.email, {'attr': {'class': 'form-control'}}) }}
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-envelope me-1"></i>Envoyer le lien
|
||||
</button>
|
||||
{{ form_end(form) }}
|
||||
|
||||
<div class="text-center mt-3" style="font-size:.85rem;">
|
||||
<a href="{{ path('app_login') }}" style="color:var(--muted);">
|
||||
<i class="bi bi-arrow-left me-1"></i>Retour au login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
templates/security/login.html.twig
Normal file
61
templates/security/login.html.twig
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Login - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h2 class="text-center mb-4"><i class="bi bi-server me-2"></i>ServerControl</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" type="email" name="_username" value="{{ last_username }}" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input class="form-control" type="password" name="_password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input class="form-check-input" type="checkbox" name="_remember_me" id="remember_me">
|
||||
<label class="form-check-label" for="remember_me">Remember me</label>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3 d-flex flex-column gap-1">
|
||||
<a href="{{ path('app_forgot_password') }}" style="font-size:.85rem; color:var(--muted);">Mot de passe oublié ?</a>
|
||||
<a href="{{ path('app_register') }}">Crée un compte</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 p-3 rounded" style="background:#1e2a3a; font-size:.85rem;">
|
||||
<div class="text-white-50 mb-2"><i class="bi bi-info-circle me-1"></i>Comptes de test</div>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<button class="btn btn-sm btn-outline-light text-start" onclick="fillLogin('admin@servercontrol.local','Admin1234!')">
|
||||
<span class="badge bg-danger me-1">ADMIN</span>
|
||||
admin@servercontrol.local
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light text-start" onclick="fillLogin('operator@servercontrol.local','Operator1234!')">
|
||||
<span class="badge bg-primary me-1">OPERATOR</span>
|
||||
operator@servercontrol.local
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function fillLogin(email, password) {
|
||||
document.querySelector('input[name="_username"]').value = email;
|
||||
document.querySelector('input[name="_password"]').value = password;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
30
templates/security/pending.html.twig
Normal file
30
templates/security/pending.html.twig
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Pending Access - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5 col-lg-4 text-center">
|
||||
<div class="card p-4">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-hourglass-split text-warning" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<h4 class="mb-2">Access pending</h4>
|
||||
<p class="text-muted mb-4">
|
||||
Your account <strong>{{ app.user.email }}</strong> has been created but does not yet have access to ServerControl.
|
||||
An administrator needs to assign you the <span class="badge bg-primary">OPERATOR</span> or
|
||||
<span class="badge bg-danger">ADMIN</span> role.
|
||||
</p>
|
||||
<hr>
|
||||
<p class="text-muted small mb-3">Already been granted access? Log out and back in to refresh your session.</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="{{ path('app_logout') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Refresh session
|
||||
</a>
|
||||
<a href="{{ path('app_logout') }}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
templates/security/profile.html.twig
Normal file
70
templates/security/profile.html.twig
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Mon profil - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
|
||||
<h2 class="h4 mb-4"><i class="bi bi-person-circle me-2"></i>Mon profil</h2>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width:52px;height:52px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill text-white fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold fs-5">{{ app.user.pseudo }}</div>
|
||||
<div class="text-muted small">{{ app.user.email }}</div>
|
||||
<div class="mt-1">
|
||||
{% for role in app.user.roles %}
|
||||
{% if role != 'ROLE_USER' %}
|
||||
<span class="badge bg-{{ role == 'ROLE_ADMIN' ? 'danger' : 'primary' }} small">
|
||||
{{ role|replace({'ROLE_': ''}) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong><i class="bi bi-key me-2"></i>Changer le mot de passe</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.currentPassword, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.currentPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.newPassword.first, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.first) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form_label(form.newPassword.second, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
|
||||
{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.second) }}
|
||||
{{ form_errors(form.newPassword) }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i>Enregistrer
|
||||
</button>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
templates/security/register.html.twig
Normal file
110
templates/security/register.html.twig
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Register - ServerControl{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
<style>
|
||||
.form-section-title {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: .75rem;
|
||||
padding-bottom: .4rem;
|
||||
border-bottom: 1px solid rgba(108,117,125,.2);
|
||||
}
|
||||
.field-icon { color: #6c757d; }
|
||||
.input-group-text { background: transparent; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-person-plus-fill fs-5 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Create account</h5>
|
||||
<small class="text-muted">Join ServerControl</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-person me-1"></i>Identity</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-envelope"></i></span>
|
||||
{{ form_widget(form.email, {attr: {class: 'form-control', placeholder: 'you@example.com'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Pseudo</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-at"></i></span>
|
||||
{{ form_widget(form.pseudo, {attr: {class: 'form-control', placeholder: 'Your display name'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.pseudo) }}
|
||||
</div>
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-key me-1"></i>Password</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-lock"></i></span>
|
||||
{{ form_widget(form.plainPassword.first, {attr: {class: 'form-control', placeholder: '••••••••', id: 'pwd1'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd('pwd1','eye1')">
|
||||
<i class="bi bi-eye" id="eye1"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ form_errors(form.plainPassword.first) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Confirm password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-lock-fill"></i></span>
|
||||
{{ form_widget(form.plainPassword.second, {attr: {class: 'form-control', placeholder: '••••••••', id: 'pwd2'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd('pwd2','eye2')">
|
||||
<i class="bi bi-eye" id="eye2"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ form_errors(form.plainPassword.second) }}
|
||||
{{ form_errors(form.plainPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-person-check me-1"></i>Create account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
<div class="card-footer text-center py-3 text-muted small">
|
||||
Already have an account? <a href="{{ path('app_login') }}">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function togglePwd(inputId, eyeId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const eye = document.getElementById(eyeId);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
32
templates/security/reset_password.html.twig
Normal file
32
templates/security/reset_password.html.twig
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Nouveau mot de passe - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h2 class="text-center mb-2"><i class="bi bi-shield-lock me-2"></i>Nouveau mot de passe</h2>
|
||||
<p class="text-center mb-4" style="font-size:.85rem; color:var(--muted);">
|
||||
Choisissez un nouveau mot de passe pour votre compte. Minimum 8 caracteres.
|
||||
</p>
|
||||
|
||||
{{ form_start(form) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.plainPassword.first) }}
|
||||
{{ form_widget(form.plainPassword.first, {'attr': {'class': 'form-control'}}) }}
|
||||
{{ form_errors(form.plainPassword.first) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.plainPassword.second) }}
|
||||
{{ form_widget(form.plainPassword.second, {'attr': {'class': 'form-control'}}) }}
|
||||
{{ form_errors(form.plainPassword.second) }}
|
||||
</div>
|
||||
{{ form_errors(form.plainPassword) }}
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-circle me-1"></i>Enregistrer le nouveau mot de passe
|
||||
</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
128
templates/server/edit.html.twig
Normal file
128
templates/server/edit.html.twig
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit {{ server.name }} - ServerControl{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
<style>
|
||||
.form-section-title {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: .75rem;
|
||||
padding-bottom: .4rem;
|
||||
border-bottom: 1px solid rgba(108,117,125,.2);
|
||||
}
|
||||
.field-icon { color: #6c757d; }
|
||||
.input-group-text { background: transparent; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to {{ server.name }}
|
||||
</a>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-pencil-square fs-5 text-warning"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Edit server</h5>
|
||||
<small class="text-muted">{{ server.name }} — {{ server.ipAddress }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-info-circle me-1"></i>General</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Server name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-tag"></i></span>
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-card-text"></i></span>
|
||||
{{ form_widget(form.description, {attr: {class: 'form-control', rows: 2}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.description) }}
|
||||
</div>
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-terminal me-1"></i>SSH Connection</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">IP Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-router"></i></span>
|
||||
{{ form_widget(form.ipAddress, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.ipAddress) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">SSH Port</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-plug"></i></span>
|
||||
{{ form_widget(form.sshPort, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshPort) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">SSH User</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-person-badge"></i></span>
|
||||
{{ form_widget(form.sshUser, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshUser) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">SSH Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-key"></i></span>
|
||||
{{ form_widget(form.sshPassword, {attr: {class: 'form-control', placeholder: 'Leave blank to keep current password', id: 'ssh-password-input'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd()">
|
||||
<i class="bi bi-eye" id="pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>Leave blank to keep the current password.</div>
|
||||
{{ form_errors(form.sshPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 pt-2 border-top">
|
||||
<button type="submit" class="btn btn-warning px-4">
|
||||
<i class="bi bi-floppy me-1"></i>Save changes
|
||||
</button>
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function togglePwd() {
|
||||
const input = document.getElementById('ssh-password-input');
|
||||
const eye = document.getElementById('pwd-eye');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
131
templates/server/index.html.twig
Normal file
131
templates/server/index.html.twig
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Servers - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">Servers</h1>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_server_new') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Server
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" name="search" placeholder="Search by name or IP..."
|
||||
value="{{ search ?? '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select" name="status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="online" {% if status == 'online' %}selected{% endif %}>Online</option>
|
||||
<option value="offline" {% if status == 'offline' %}selected{% endif %}>Offline</option>
|
||||
<option value="unknown" {% if status == 'unknown' %}selected{% endif %}>Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-secondary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if servers|length == 0 %}
|
||||
<div class="alert alert-info">Aucun serveur trouver.</div>
|
||||
{% else %}
|
||||
<div class="table-responsive card">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>SSH Port</th>
|
||||
<th>Status</th>
|
||||
<th>Owner</th>
|
||||
<th>Added</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td><strong>{{ server.name }}</strong></td>
|
||||
<td><code>{{ server.ipAddress }}</code></td>
|
||||
<td>{{ server.sshPort }}</td>
|
||||
<td>
|
||||
<span class="status-{{ server.lastStatus }}" id="status-{{ server.id }}">
|
||||
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>
|
||||
{{ server.lastStatus|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ server.owner.pseudo }}</td>
|
||||
<td>{{ server.createdAt|date('d/m/Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_server_edit', {id: server.id}) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal{{ server.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex justify-content-center">
|
||||
{{ knp_pagination_render(servers) }}
|
||||
</div>
|
||||
|
||||
{% for server in servers %}
|
||||
<div class="modal fade" id="deleteModal{{ server.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Server</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Êtes-vous sûre de vouloir suprimmer <strong>{{ server.name }}</strong> ?
|
||||
Tout les services et journaux associé seront supprimés.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="post" action="{{ path('app_server_delete', {id: server.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_server_' ~ server.id) }}">
|
||||
<button class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function refreshStatuses() {
|
||||
document.querySelectorAll('[id^="status-"]').forEach(el => {
|
||||
const id = el.id.replace('status-', '');
|
||||
fetch(`/server/${id}/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
el.className = `status-${data.status}`;
|
||||
el.innerHTML = `<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>${data.status.charAt(0).toUpperCase() + data.status.slice(1)}`;
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
setInterval(refreshStatuses, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
110
templates/server/new.html.twig
Normal file
110
templates/server/new.html.twig
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Add Server - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
<a href="{{ path('app_server_index') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to servers
|
||||
</a>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-hdd-stack-fill fs-5 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Ajouter un nouveaux serveur</h5>
|
||||
<small class="text-muted">Renseignez les détails de connection SSH</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-info-circle me-1"></i>General</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Server name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-tag"></i></span>
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control', placeholder: 'e.g. Web server prod'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-card-text"></i></span>
|
||||
{{ form_widget(form.description, {attr: {class: 'form-control', placeholder: 'Short description…', rows: 2}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.description) }}
|
||||
</div>
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-terminal me-1"></i>SSH Connection</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">IP Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-router"></i></span>
|
||||
{{ form_widget(form.ipAddress, {attr: {class: 'form-control', placeholder: '192.168.1.10'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.ipAddress) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">SSH Port</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-plug"></i></span>
|
||||
{{ form_widget(form.sshPort, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshPort) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">SSH User</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-person-badge"></i></span>
|
||||
{{ form_widget(form.sshUser, {attr: {class: 'form-control', placeholder: 'e.g. root, deploy'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshUser) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">SSH Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-key"></i></span>
|
||||
{{ form_widget(form.sshPassword, {attr: {class: 'form-control', placeholder: '••••••••', id: 'ssh-password-input'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd()">
|
||||
<i class="bi bi-eye" id="pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ form_errors(form.sshPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter serveur
|
||||
</button>
|
||||
<a href="{{ path('app_server_index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function togglePwd() {
|
||||
const input = document.getElementById('ssh-password-input');
|
||||
const eye = document.getElementById('pwd-eye');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
502
templates/server/show.html.twig
Normal file
502
templates/server/show.html.twig
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ server.name }} - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ path('app_server_index') }}" class="text-muted text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Retour aux serveur
|
||||
</a>
|
||||
<h1 class="h3 mt-1">{{ server.name }}</h1>
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div>
|
||||
<a href="{{ path('app_server_edit', {id: server.id}) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><strong>Server Info</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5">IP Address</dt>
|
||||
<dd class="col-7"><code>{{ server.ipAddress }}</code></dd>
|
||||
<dt class="col-5">SSH Port</dt>
|
||||
<dd class="col-7">{{ server.sshPort }}</dd>
|
||||
<dt class="col-5">SSH User</dt>
|
||||
<dd class="col-7">{{ server.sshUser }}</dd>
|
||||
<dt class="col-5">Status</dt>
|
||||
<dd class="col-7">
|
||||
<span class="status-{{ server.lastStatus }}" id="server-status">
|
||||
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>
|
||||
{{ server.lastStatus|capitalize }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="col-5">Owner</dt>
|
||||
<dd class="col-7">{{ server.owner.pseudo }}</dd>
|
||||
<dt class="col-5">Added</dt>
|
||||
<dd class="col-7">{{ server.createdAt|date('d/m/Y') }}</dd>
|
||||
{% if server.description %}
|
||||
<dt class="col-5">Description</dt>
|
||||
<dd class="col-7">{{ server.description }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card h-100" id="terminal-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-terminal me-2"></i>Terminal SSH</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="termClear()">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="terminal-titlebar">
|
||||
<span class="terminal-host">{{ server.sshUser }}@{{ server.ipAddress }}</span>
|
||||
</div>
|
||||
<div class="terminal-body" id="term-output">
|
||||
<div class="term-line term-info">Connecté a {{ server.name }} — tapez une commande et apuyez sur Entrée.</div>
|
||||
<div class="term-line term-warn">Note : utiliser sudo pour les opérations privilégiées — un mot de passe pourra être demandé.</div>
|
||||
</div>
|
||||
<div class="terminal-input-row">
|
||||
<span class="terminal-ps1">{{ server.sshUser }}@{{ server.name }}:~$ </span>
|
||||
<input class="terminal-cmd" id="term-input" type="text"
|
||||
placeholder="ex: ls /etc/nginx ou sudo systemctl status nginx"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-people me-2"></i>Operator Access</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#addAccessForm">
|
||||
<i class="bi bi-plus-lg"></i> Grant access
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="addAccessForm">
|
||||
<div class="card-body border-bottom">
|
||||
<form method="post" action="{{ path('app_access_new', {serverId: server.id}) }}" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('add_access_' ~ server.id) }}">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">User</label>
|
||||
<select name="user_id" class="form-select form-select-sm" required>
|
||||
<option value="">— select —</option>
|
||||
{% for u in assignableUsers %}
|
||||
<option value="{{ u.id }}">{{ u.pseudo }} ({{ u.email }})</option>
|
||||
{% else %}
|
||||
<option disabled>No more users to assign</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">SSH User</label>
|
||||
<input class="form-control form-control-sm" name="ssh_user" placeholder="e.g. deploy" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">SSH Password</label>
|
||||
<input class="form-control form-control-sm" name="ssh_password" type="password" placeholder="••••••" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-sm btn-primary w-100">Grant</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if server.serverAccesses|length == 0 %}
|
||||
<p class="text-muted p-3 mb-0">Aucun opérateur assignée pour l'instant.</p>
|
||||
{% else %}
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th>Operator</th><th>Email</th><th>SSH User</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for access in server.serverAccesses %}
|
||||
<tr>
|
||||
<td>{{ access.user.pseudo }}</td>
|
||||
<td class="text-muted small">{{ access.user.email }}</td>
|
||||
<td><code>{{ access.sshUser }}</code></td>
|
||||
<td>
|
||||
<form method="post" action="{{ path('app_access_delete', {serverId: server.id, accessId: access.id}) }}"
|
||||
onsubmit="return confirm('Revoke access for {{ access.user.pseudo }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_access_' ~ access.id) }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-person-x"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Services</strong>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#addServiceForm">
|
||||
<i class="bi bi-plus-lg"></i> Ajouter service
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div class="collapse" id="addServiceForm">
|
||||
<div class="card-body border-bottom">
|
||||
<form method="post" action="{{ path('app_service_new', {serverId: server.id}) }}" class="row g-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('add_service_' ~ server.id) }}">
|
||||
<div class="col-md-5">
|
||||
<input class="form-control form-control-sm" name="name" placeholder="Nom systemd (ex: nginx)" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input class="form-control form-control-sm" name="display_name" placeholder="Nom d'affichage" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-sm btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
{% if server.services|length == 0 %}
|
||||
<p class="text-muted mb-0">Aucun service configurée.</p>
|
||||
{% else %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
{% for service in server.services %}
|
||||
<div class="col">
|
||||
<div class="card h-100 service-card">
|
||||
<div class="card-body pb-2">
|
||||
<div class="fw-semibold">{{ service.displayName }}</div>
|
||||
<code class="small text-muted">{{ service.name }}</code>
|
||||
<div class="mt-2">
|
||||
<span class="badge service-status-badge"
|
||||
id="svc-status-{{ service.name|replace({'.': '-', '@': '-', ':': '-'}) }}"
|
||||
data-service="{{ service.name }}">
|
||||
<span class="spinner-border spinner-border-sm" style="width:.7em;height:.7em;border-width:1px;"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-success service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="start" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Démarrer">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="stop" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Arrêter">
|
||||
<i class="bi bi-stop-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="restart" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Redémarrer">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</button>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form method="post"
|
||||
action="{{ path('app_service_delete', {serverId: server.id, serviceId: service.id}) }}"
|
||||
onsubmit="return confirm('Delete service « {{ service.displayName }} »?')"
|
||||
class="ms-auto">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_service_' ~ service.id) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="sudoModal" tabindex="-1" aria-labelledby="sudoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="sudoModalLabel">
|
||||
<i class="bi bi-shield-lock me-2 text-warning"></i>Mot de passe sudo requis
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">
|
||||
La commande <code id="sudo-cmd-preview"></code> nécessite des privilèges élevés.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" id="sudo-password-input"
|
||||
placeholder="mot de passe sudo" autocomplete="current-password">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="toggleSudoPwd()">
|
||||
<i class="bi bi-eye" id="sudo-pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sudo-error" class="text-danger small mt-2 d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Mot de passe incorect, réessayez.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" id="sudo-confirm-btn">
|
||||
<i class="bi bi-send me-1"></i>Exécuter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<strong>Journaux récent</strong>
|
||||
<a href="{{ path('app_log_index', {server: server.id}) }}" class="btn btn-sm btn-link">Voir tout</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if server.actionLogs|length == 0 %}
|
||||
<p class="text-muted p-3 mb-0">Aucun journaux pour l'instant.</p>
|
||||
{% else %}
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Action</th><th>Status</th><th>User</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in server.actionLogs|slice(0, 10) %}
|
||||
<tr>
|
||||
<td>{{ log.executedAt|date('d/m/Y H:i') }}</td>
|
||||
<td><code>{{ log.actionType }}</code></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ log.status == 'success' ? 'success' : 'danger' }}">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ log.user ? log.user.pseudo : 'deleted' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
const TERM_CSRF = '{{ csrf_token('terminal_' ~ server.id) }}';
|
||||
const ACTION_CSRF = '{{ csrf_token('server_action') }}';
|
||||
const SERVER_ID = {{ server.id }};
|
||||
const SSH_USER = '{{ server.sshUser }}';
|
||||
const SERVER_NAME = '{{ server.name }}';
|
||||
|
||||
const cmdHistory = [];
|
||||
let historyPos = -1;
|
||||
|
||||
function termLine(text, cls) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'term-line ' + (cls || '');
|
||||
d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function termAppend(text, cls) {
|
||||
const out = document.getElementById('term-output');
|
||||
if (!text) return;
|
||||
text.split('\n').forEach(l => { if (l !== '') out.appendChild(termLine(l, cls)); });
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
function termClear() {
|
||||
document.getElementById('term-output').innerHTML =
|
||||
'<div class="term-line term-info">— Terminal effacé —</div>';
|
||||
}
|
||||
function removePending(marker) {
|
||||
const out = document.getElementById('term-output');
|
||||
const el = [...out.querySelectorAll('.term-line.term-info')].findLast(e => e.textContent === marker);
|
||||
if (el) el.remove();
|
||||
}
|
||||
function showResult(data, isOk) {
|
||||
const cls = isOk ? 'term-success' : 'term-error';
|
||||
if (data.output) termAppend(data.output.trimEnd(), cls);
|
||||
termAppend(isOk ? '[OK]' : '[FAILED]', cls);
|
||||
}
|
||||
|
||||
function loadServiceStatuses() {
|
||||
fetch(`/server/${SERVER_ID}/services/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
Object.entries(data).forEach(([name, status]) => {
|
||||
const safeId = name.replace(/[.@:]/g, '-');
|
||||
const el = document.getElementById(`svc-status-${safeId}`);
|
||||
if (!el) return;
|
||||
if (status === 'active') {
|
||||
el.className = 'badge bg-success';
|
||||
el.textContent = 'Actif';
|
||||
} else if (status === 'inactive') {
|
||||
el.className = 'badge bg-danger';
|
||||
el.textContent = 'Inactif';
|
||||
} else {
|
||||
el.className = 'badge bg-secondary';
|
||||
el.textContent = 'Inconnu';
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
document.querySelectorAll('.service-status-badge').forEach(el => {
|
||||
el.className = 'badge bg-secondary';
|
||||
el.textContent = 'Inconnu';
|
||||
});
|
||||
});
|
||||
}
|
||||
loadServiceStatuses();
|
||||
|
||||
let pending = null;
|
||||
const sudoModal = new bootstrap.Modal(document.getElementById('sudoModal'));
|
||||
|
||||
function showSudoModal(pendingData, isRetry) {
|
||||
pending = pendingData;
|
||||
const label = pendingData.type === 'action'
|
||||
? `sudo systemctl ${pendingData.action} ${pendingData.service}`
|
||||
: pendingData.command;
|
||||
document.getElementById('sudo-cmd-preview').textContent = label;
|
||||
document.getElementById('sudo-password-input').value = '';
|
||||
document.getElementById('sudo-error').classList.toggle('d-none', !isRetry);
|
||||
sudoModal.show();
|
||||
setTimeout(() => document.getElementById('sudo-password-input').focus(), 350);
|
||||
}
|
||||
|
||||
document.getElementById('sudo-confirm-btn').addEventListener('click', submitSudo);
|
||||
document.getElementById('sudo-password-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitSudo();
|
||||
});
|
||||
|
||||
function submitSudo() {
|
||||
const pwd = document.getElementById('sudo-password-input').value.trim();
|
||||
if (!pwd) return;
|
||||
sudoModal.hide();
|
||||
termAppend('[sudo] authentification en cours…', 'term-warn');
|
||||
if (pending.type === 'terminal') {
|
||||
execTerminalCommand(pending.command, pwd);
|
||||
} else {
|
||||
execServiceAction(pending.action, pending.service, pending.serverId, pwd);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSudoPwd() {
|
||||
const inp = document.getElementById('sudo-password-input');
|
||||
const eye = document.getElementById('sudo-pwd-eye');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
eye.className = inp.type === 'password' ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
}
|
||||
|
||||
function execTerminalCommand(command, sudoPassword = null) {
|
||||
const form = new FormData();
|
||||
form.append('_token', TERM_CSRF);
|
||||
form.append('command', command);
|
||||
if (sudoPassword) form.append('sudo_password', sudoPassword);
|
||||
|
||||
return fetch(`/server/${SERVER_ID}/terminal`, { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
removePending('…');
|
||||
if (data.needs_sudo) {
|
||||
if (sudoPassword) {
|
||||
termAppend('[sudo] mot de passe incorrect', 'term-error');
|
||||
setTimeout(() => showSudoModal({ type: 'terminal', command }, true), 300);
|
||||
} else {
|
||||
showSudoModal({ type: 'terminal', command }, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
showResult(data, data.success);
|
||||
}).catch(() => termAppend('Network error.', 'term-error'));
|
||||
}
|
||||
|
||||
const termInput = document.getElementById('term-input');
|
||||
termInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const cmd = this.value.trim();
|
||||
if (!cmd) return;
|
||||
cmdHistory.unshift(cmd);
|
||||
if (cmdHistory.length > 50) cmdHistory.pop();
|
||||
historyPos = -1;
|
||||
this.value = '';
|
||||
termAppend(`${SSH_USER}@${SERVER_NAME}:~$ ${cmd}`, 'term-prompt');
|
||||
if (cmd === 'clear' || cmd === 'cls') { termClear(); return; }
|
||||
termAppend('…', 'term-info');
|
||||
execTerminalCommand(cmd);
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (historyPos < cmdHistory.length - 1) termInput.value = cmdHistory[++historyPos];
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
historyPos > 0 ? termInput.value = cmdHistory[--historyPos] : (historyPos = -1, termInput.value = '');
|
||||
}
|
||||
});
|
||||
document.querySelector('.terminal-body').addEventListener('click', () => termInput.focus());
|
||||
|
||||
function execServiceAction(action, service, serverId, sudoPassword = null) {
|
||||
const form = new FormData();
|
||||
form.append('action', action);
|
||||
form.append('service', service);
|
||||
form.append('_token', ACTION_CSRF);
|
||||
if (sudoPassword) form.append('sudo_password', sudoPassword);
|
||||
|
||||
return fetch(`/server/${serverId}/action`, { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
removePending('Exécution…');
|
||||
if (data.needs_sudo) {
|
||||
if (sudoPassword) {
|
||||
termAppend('[sudo] mot de passe incorrect', 'term-error');
|
||||
setTimeout(() => showSudoModal({ type: 'action', action, service, serverId }, true), 300);
|
||||
} else {
|
||||
showSudoModal({ type: 'action', action, service, serverId }, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
showResult(data, data.status === 'success');
|
||||
loadServiceStatuses();
|
||||
}).catch(() => termAppend('Network error.', 'term-error'));
|
||||
}
|
||||
|
||||
document.querySelectorAll('.service-action').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const action = this.dataset.action;
|
||||
const service = this.dataset.service;
|
||||
const srvId = this.dataset.server;
|
||||
termAppend(`${SSH_USER}@${SERVER_NAME}:~$ sudo systemctl ${action} ${service}`, 'term-prompt');
|
||||
termAppend('Exécution…', 'term-info');
|
||||
execServiceAction(action, service, srvId);
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
fetch(`/server/${SERVER_ID}/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const el = document.getElementById('server-status');
|
||||
if (el) {
|
||||
el.className = `status-${data.status}`;
|
||||
el.innerHTML = `<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>${data.status.charAt(0).toUpperCase() + data.status.slice(1)}`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue