ajout trop de chose
rappel pour moi : a ne plus reproduire
This commit is contained in:
parent
e6579cbb90
commit
303fc7bcb5
85 changed files with 5959 additions and 139 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
/.env.local
|
/.env.local
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
|
|
@ -20,3 +21,5 @@
|
||||||
/public/assets/
|
/public/assets/
|
||||||
/assets/vendor/
|
/assets/vendor/
|
||||||
###< symfony/asset-mapper ###
|
###< symfony/asset-mapper ###
|
||||||
|
|
||||||
|
/src/uploads
|
||||||
|
|
|
||||||
188
api-client.php
Executable file
188
api-client.php
Executable file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exemple de client API PHP pour consommer votre API
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php api-client.php health
|
||||||
|
* php api-client.php profile YOUR_TOKEN
|
||||||
|
* php api-client.php keys YOUR_TOKEN
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
private string $baseUrl;
|
||||||
|
private string $token;
|
||||||
|
|
||||||
|
public function __construct(string $baseUrl, string $token = '')
|
||||||
|
{
|
||||||
|
$this->baseUrl = rtrim($baseUrl, '/');
|
||||||
|
$this->token = $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effectuer une requête API
|
||||||
|
*/
|
||||||
|
private function request(string $method, string $endpoint, ?array $data = null): array
|
||||||
|
{
|
||||||
|
$url = $this->baseUrl . '/api' . $endpoint;
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->token) {
|
||||||
|
$options['http']['header'][] = "Authorization: Bearer {$this->token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data && in_array($method, ['POST', 'PATCH', 'PUT'])) {
|
||||||
|
$options['http']['content'] = json_encode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create($options);
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
return [
|
||||||
|
'error' => 'Erreur de connexion',
|
||||||
|
'url' => $url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode($response, true) ?? ['error' => 'Réponse invalide'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints
|
||||||
|
|
||||||
|
public function health(): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile(): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listKeys(): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/api-keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createKey(string $name, ?string $expiresAt = null): array
|
||||||
|
{
|
||||||
|
$data = ['name' => $name];
|
||||||
|
if ($expiresAt) {
|
||||||
|
$data['expiresAt'] = $expiresAt;
|
||||||
|
}
|
||||||
|
return $this->request('POST', '/api-keys', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKey(int $id): array
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', "/api-keys/{$id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivateKey(int $id): array
|
||||||
|
{
|
||||||
|
return $this->request('PATCH', "/api-keys/{$id}/deactivate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utilisation de ligne de commande ---
|
||||||
|
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
die('Ce script doit être exécuté en ligne de commande');
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = 'http://localhost:8000';
|
||||||
|
$command = $argv[1] ?? 'health';
|
||||||
|
$token = $argv[2] ?? '';
|
||||||
|
|
||||||
|
$client = new ApiClient($baseUrl, $token);
|
||||||
|
|
||||||
|
match($command) {
|
||||||
|
'health' => output_result('Health Check', $client->health()),
|
||||||
|
'profile' => require_token($token, fn() => output_result('Profil utilisateur', $client->profile())),
|
||||||
|
'keys' => require_token($token, fn() => output_result('Clés API', $client->listKeys())),
|
||||||
|
'create-key' => require_token($token, function() use ($client) {
|
||||||
|
$name = $argv[3] ?? 'Nouvelle clé';
|
||||||
|
$expires = $argv[4] ?? null;
|
||||||
|
output_result('Création de clé', $client->createKey($name, $expires));
|
||||||
|
}),
|
||||||
|
'delete-key' => require_token($token, function() use ($client) {
|
||||||
|
if (empty($argv[3])) {
|
||||||
|
echo "Usage: php api-client.php delete-key TOKEN KEY_ID\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_result('Suppression de clé', $client->deleteKey((int)$argv[3]));
|
||||||
|
}),
|
||||||
|
'deactivate-key' => require_token($token, function() use ($client) {
|
||||||
|
if (empty($argv[3])) {
|
||||||
|
echo "Usage: php api-client.php deactivate-key TOKEN KEY_ID\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_result('Désactivation de clé', $client->deactivateKey((int)$argv[3]));
|
||||||
|
}),
|
||||||
|
default => show_help(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function require_token(string $token, callable $fn)
|
||||||
|
{
|
||||||
|
if (!$token) {
|
||||||
|
echo "Erreur: Ce commande nécessite un token\n";
|
||||||
|
echo "Usage: php api-client.php COMMAND TOKEN [OPTIONS]\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function output_result(string $title, array $result)
|
||||||
|
{
|
||||||
|
echo "\n$title\n";
|
||||||
|
echo str_repeat('=', strlen($title)) . "\n\n";
|
||||||
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_help()
|
||||||
|
{
|
||||||
|
echo <<<'EOT'
|
||||||
|
|
||||||
|
📡 Client API pour votre application
|
||||||
|
|
||||||
|
Commandes disponibles:
|
||||||
|
|
||||||
|
health - Vérifier l'état de l'API (pas de token requis)
|
||||||
|
profile TOKEN - Afficher le profil utilisateur
|
||||||
|
keys TOKEN - Lister les clés API
|
||||||
|
create-key TOKEN [NAME] [EXP] - Créer une nouvelle clé API
|
||||||
|
delete-key TOKEN KEY_ID - Supprimer une clé API
|
||||||
|
deactivate-key TOKEN KEY_ID - Désactiver une clé API
|
||||||
|
|
||||||
|
Exemples:
|
||||||
|
|
||||||
|
# Vérifier l'état
|
||||||
|
php api-client.php health
|
||||||
|
|
||||||
|
# Afficher le profil
|
||||||
|
php api-client.php profile abc123...xyz
|
||||||
|
|
||||||
|
# Lister les clés
|
||||||
|
php api-client.php keys abc123...xyz
|
||||||
|
|
||||||
|
# Créer une clé avec expiration
|
||||||
|
php api-client.php create-key abc123...xyz "Mon App" "2026-12-31"
|
||||||
|
|
||||||
|
# Supprimer une clé
|
||||||
|
php api-client.php delete-key abc123...xyz 1
|
||||||
|
|
||||||
|
EOT;
|
||||||
|
}
|
||||||
|
|
||||||
11
assets/controllers/clickable-card_controller.js
Normal file
11
assets/controllers/clickable-card_controller.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
navigate(e) {
|
||||||
|
window.location = this.element.dataset.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPropagation(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,30 @@
|
||||||
import { Controller } from '@hotwired/stimulus';
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrôleur Stimulus pour le dropdown de connexion
|
* Contrôleur Stimulus pour le dropdown de connexion
|
||||||
* Attache les événements de manière robuste et compatible avec Turbo
|
* Attache les événements de manière robuste et compatible avec Turbo
|
||||||
*/
|
*/
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['wrapper', 'toggle', 'dropdown'];
|
static targets = ['wrapper', 'toggle', 'dropdown'];
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
|
|
||||||
// Ouvrir le dropdown s'il y a une erreur de connexion
|
// Ouvrir le dropdown s'il y a une erreur de connexion
|
||||||
const loginError = this.element.querySelector('.login-error');
|
const loginError = this.element.querySelector('.login-error');
|
||||||
if (loginError && this.dropdownTarget) {
|
if (loginError && this.dropdownTarget) {
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupListeners() {
|
setupListeners() {
|
||||||
// Clic sur le bouton toggle
|
// Clic sur le bouton toggle
|
||||||
this.toggleTarget.addEventListener('click', (e) => {
|
this.toggleTarget.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggle();
|
this.toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clic en dehors du dropdown
|
// Clic en dehors du dropdown
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!this.wrapperTarget.contains(e.target)) {
|
if (!this.wrapperTarget.contains(e.target)) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touche Echap
|
// Touche Echap
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|
@ -38,20 +32,16 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
const isOpen = this.dropdownTarget.classList.contains('open');
|
const isOpen = this.dropdownTarget.classList.contains('open');
|
||||||
isOpen ? this.close() : this.open();
|
isOpen ? this.close() : this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
this.dropdownTarget.classList.add('open');
|
this.dropdownTarget.classList.add('open');
|
||||||
this.toggleTarget.setAttribute('aria-expanded', 'true');
|
this.toggleTarget.setAttribute('aria-expanded', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.dropdownTarget.classList.remove('open');
|
this.dropdownTarget.classList.remove('open');
|
||||||
this.toggleTarget.setAttribute('aria-expanded', 'false');
|
this.toggleTarget.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
26
assets/controllers/mod_optional_controller.js
Normal file
26
assets/controllers/mod_optional_controller.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['row'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.rowTargets.forEach(row => this.#syncRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(event) {
|
||||||
|
this.#syncRow(event.target.closest('[data-mod-optional-target="row"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#syncRow(row) {
|
||||||
|
const modCheckbox = row.querySelector('[data-mod-checkbox]');
|
||||||
|
const toggle = row.querySelector('[data-opt-toggle]');
|
||||||
|
const uiOpt = row.querySelector('[data-ui-opt]');
|
||||||
|
|
||||||
|
if (!modCheckbox || !toggle) return;
|
||||||
|
|
||||||
|
const checked = modCheckbox.checked;
|
||||||
|
toggle.classList.toggle('d-none', !checked);
|
||||||
|
|
||||||
|
if (!checked && uiOpt) uiOpt.checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
assets/controllers/modpack_download_controller.js
Normal file
21
assets/controllers/modpack_download_controller.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// assets/controllers/modpack_download_controller.js
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String }
|
||||||
|
|
||||||
|
submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const checked = [...this.element.querySelectorAll('input[type=checkbox]:checked')]
|
||||||
|
.map(el => el.value)
|
||||||
|
|
||||||
|
const url = this.urlValue + (checked.length ? '?opt=' + checked.join('+') : '')
|
||||||
|
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
changeVersion(event) {
|
||||||
|
window.location.href = '?v=' + event.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
21
assets/controllers/tom-select_controller.js
Normal file
21
assets/controllers/tom-select_controller.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// assets/controllers/tom-select_controller.js
|
||||||
|
|
||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
import TomSelect from 'tom-select';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
new TomSelect(this.element, {
|
||||||
|
plugins: ['remove_button'],
|
||||||
|
placeholder: this.element.dataset.placeholder ?? 'Rechercher une dépendance...',
|
||||||
|
maxOptions: null,
|
||||||
|
wrapperClass: 'ts-wrapper form-tom-select',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.element.tomselect) {
|
||||||
|
this.element.tomselect.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
assets/styles/administration/index.css
Normal file
66
assets/styles/administration/index.css
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Structure interne de la carte */
|
||||||
|
a.admin-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.admin-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] a.admin-card {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--violet);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .admin-card__icon {
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card__icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card__desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted-light);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .admin-card__desc {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
153
assets/styles/administration/modpacks/index.css
Normal file
153
assets/styles/administration/modpacks/index.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* ── CARTE modpack ─────────────────────────────────────────────────── */
|
||||||
|
a.modpack-card,
|
||||||
|
div.modpack-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] a.modpack-card,
|
||||||
|
[data-theme="dark"] div.modpack-card {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.modpack-card:hover,
|
||||||
|
div.modpack-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ICÔNE modpack ─────────────────────────────────────────────────── */
|
||||||
|
.modpack-add-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
transition: border-color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-add-icon {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NOM DU modpack ────────────────────────────────────────────────── */
|
||||||
|
.modpack-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ESPACE FLEXIBLE ───────────────────────────────────────────── */
|
||||||
|
.modpack-space {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PILLS COMMUNES ────────────────────────────────────────────── */
|
||||||
|
.modpack-number,
|
||||||
|
.modpack-used,
|
||||||
|
.modpack-dep {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NUMBER ───────────────────────────────────────────────────── */
|
||||||
|
.modpack-number {
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-number {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DÉPENDANCES ───────────────────────────────────────────────── */
|
||||||
|
.modpack-deps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.modpack-dep {
|
||||||
|
background: rgba(245, 158, 11, 0.10);
|
||||||
|
color: #92400e;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modpack-dep:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.20);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-dep {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-dep:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── UTILISÉ / NON UTILISÉ ─────────────────────────────────────── */
|
||||||
|
.modpack-used {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modpack-used.non-utilise {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: var(--muted-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-used.non-utilise {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SUPPRIMER ─────────────────────────────────────────────────── */
|
||||||
|
.modpack-delete {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: #ef4444;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--duration-fast), color var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modpack-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-delete {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modpack-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.22);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
153
assets/styles/administration/mods/index.css
Normal file
153
assets/styles/administration/mods/index.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* ── CARTE MOD ─────────────────────────────────────────────────── */
|
||||||
|
a.mod-card,
|
||||||
|
div.mod-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] a.mod-card,
|
||||||
|
[data-theme="dark"] div.mod-card {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mod-card:hover,
|
||||||
|
div.mod-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ICÔNE MOD ─────────────────────────────────────────────────── */
|
||||||
|
.mod-add-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
transition: border-color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-add-icon {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NOM DU MOD ────────────────────────────────────────────────── */
|
||||||
|
.mod-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ESPACE FLEXIBLE ───────────────────────────────────────────── */
|
||||||
|
.mod-space {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PILLS COMMUNES ────────────────────────────────────────────── */
|
||||||
|
.mod-version,
|
||||||
|
.mod-used,
|
||||||
|
.mod-dep {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── VERSION ───────────────────────────────────────────────────── */
|
||||||
|
.mod-version {
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-version {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DÉPENDANCES ───────────────────────────────────────────────── */
|
||||||
|
.mod-deps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-dep {
|
||||||
|
background: rgba(245, 158, 11, 0.10);
|
||||||
|
color: #92400e;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-dep:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.20);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-dep {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-dep:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── UTILISÉ / NON UTILISÉ ─────────────────────────────────────── */
|
||||||
|
.mod-used {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-used.non-utilise {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: var(--muted-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-used.non-utilise {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SUPPRIMER ─────────────────────────────────────────────────── */
|
||||||
|
.mod-delete {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: #ef4444;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--duration-fast), color var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-delete {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .mod-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.22);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
104
assets/styles/administration/users/index.css
Normal file
104
assets/styles/administration/users/index.css
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/* ── CARTE UTILISATEUR ─────────────────────────────────────────── */
|
||||||
|
a.user-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] a.user-card {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.user-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PHOTO DE PROFIL ───────────────────────────────────────────── */
|
||||||
|
.pp-img,
|
||||||
|
.pp-svg-wrapper {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
transition: border-color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .pp-img,
|
||||||
|
[data-theme="dark"] .pp-svg-wrapper {
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-svg-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .pp-svg-wrapper {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-svg {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COLONNES INFOS ────────────────────────────────────────────── */
|
||||||
|
.user-pseudo {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-space {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .user-uripp {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role,
|
||||||
|
.user-editable {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .user-role {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-editable {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-editable.non-modifiable {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: var(--muted-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .user-editable.non-modifiable {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
/* Couleurs de texte */
|
/* Couleurs de texte */
|
||||||
--text-light: #1a1625;
|
--text-light: #1a1625;
|
||||||
--text-dark: #ede9f6;
|
--text-dark: #ede9f6;
|
||||||
--muted-light: #6b7280;
|
--muted-light: #595d6a;
|
||||||
--muted-dark: #9ca3af;
|
--muted-dark: #9ca3af;
|
||||||
|
|
||||||
/* Couleurs de bordure */
|
/* Couleurs de bordure */
|
||||||
|
|
@ -61,13 +61,20 @@ body {
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
padding-top: 72px;
|
padding-top: 72px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] body {
|
[data-theme="dark"] body {
|
||||||
background-image: url('../images/bg-dark.png');
|
background-image: url('../images/bg-dark.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── STYLES POUR LE LIEN COPIABLE ──────────────────────────────── */
|
/* ── STYLES POUR LE LIEN COPIABLE ──────────────────────────────── */
|
||||||
.copy-link {
|
.copy-link {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
color: var(--text-dark);
|
color: var(--text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulle:hover{
|
.bulle:not(.bulle-nohover):hover{
|
||||||
box-shadow: 0 16px 40px rgba(124, 58, 237, 0.20);
|
box-shadow: 0 16px 40px rgba(124, 58, 237, 0.20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,3 +48,26 @@
|
||||||
.bulle-container>.bulle.bulle-2-row {
|
.bulle-container>.bulle.bulle-2-row {
|
||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HEADER */
|
||||||
|
|
||||||
|
.bulle-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulle-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bulle-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
|
||||||
512
assets/styles/globals/form.css
Normal file
512
assets/styles/globals/form.css
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
/* ── FORM WRAPPER ──────────────────────────────────────────────── */
|
||||||
|
.form-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-wrapper__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-wrapper__col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-wrapper__row-line {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-wrapper__row-line {
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GROUPES ───────────────────────────────────────────────────── */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-label {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── INPUTS ────────────────────────────────────────────────────── */
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background-color: var(--nav-bg-light);
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--duration-normal), box-shadow var(--duration-normal), background var(--duration-slow), color var(--duration-slow);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-input {
|
||||||
|
background-color: var(--nav-bg-dark);
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--violet);
|
||||||
|
box-shadow: 0 0 0 3px var(--violet-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%237C3AED' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 0.75rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-select {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23A78BFA' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FILE INPUT ────────────────────────────────────────────────── */
|
||||||
|
.form-file {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed var(--border-light);
|
||||||
|
background-color: var(--nav-bg-light);
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--duration-normal),
|
||||||
|
box-shadow var(--duration-normal),
|
||||||
|
background var(--duration-slow),
|
||||||
|
color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-file {
|
||||||
|
background-color: var(--nav-bg-dark);
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-file:hover {
|
||||||
|
border-color: var(--violet-light);
|
||||||
|
background-color: var(--pill-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-file:hover {
|
||||||
|
background-color: var(--pill-bg-dark);
|
||||||
|
border-color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-file:focus {
|
||||||
|
border-color: var(--violet);
|
||||||
|
border-style: solid;
|
||||||
|
box-shadow: 0 0 0 3px var(--violet-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-file:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton natif "Choisir un fichier" */
|
||||||
|
.form-file::file-selector-button {
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
color: var(--violet);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
transition: background var(--duration-fast), color var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-file::file-selector-button {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
color: var(--violet-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-file:hover::file-selector-button,
|
||||||
|
.form-file:focus::file-selector-button {
|
||||||
|
background: var(--violet);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HELP TEXT ─────────────────────────────────────────────────── */
|
||||||
|
.form-helper {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-helper {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Petite pastille d'info devant le texte */
|
||||||
|
.form-helper::before {
|
||||||
|
content: 'ℹ';
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--violet-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FOOTER ────────────────────────────────────────────────────── */
|
||||||
|
.form-wrapper__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-btn {
|
||||||
|
padding: 0.6rem 1.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--violet);
|
||||||
|
color: white;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--duration-fast), box-shadow var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 4px 20px var(--violet-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-btn--cancel {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-light);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-btn--cancel {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-btn--cancel:hover {
|
||||||
|
background: var(--pill-bg-light);
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-btn--cancel:hover {
|
||||||
|
background: var(--pill-bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ERREURS ───────────────────────────────────────────────────── */
|
||||||
|
.form-group ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group ul li {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CHECKBOX ─────────────────────────────────────────────────────── */
|
||||||
|
.form-checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox-group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-checkbox-group-item {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox-group-item input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border: 1.5px solid var(--border-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--nav-bg-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-normal), border-color var(--duration-normal);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-checkbox-group-item input[type="checkbox"] {
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
background: var(--nav-bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox-group-item input[type="checkbox"]:checked {
|
||||||
|
background: var(--violet);
|
||||||
|
border-color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox-group-item input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
transform: translate(-50%, -60%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOGGLE THEME ──────────────────────────────────────────────── */
|
||||||
|
.form-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle__track {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border-light);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--duration-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle__track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: calc(1.3rem - 6px);
|
||||||
|
height: calc(1.3rem - 6px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: transform var(--duration-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle input:checked ~ .form-toggle__track {
|
||||||
|
background: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle input:checked ~ .form-toggle__track::after {
|
||||||
|
transform: translateX(1.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle__label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color var(--duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-toggle__label.disabled {
|
||||||
|
color: var(--muted-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-toggle__label {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-toggle__label.disabled {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOM SELECT — FULL CUSTOM (sans CDN) ──────────────────────── */
|
||||||
|
.form-tom-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-control {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
border: 1px solid var(--border-light) !important;
|
||||||
|
background: var(--nav-bg-light) !important;
|
||||||
|
font-family: 'DM Sans', sans-serif !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
padding: 0.4rem 0.6rem !important;
|
||||||
|
min-height: 2.5rem !important;
|
||||||
|
cursor: text !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-tom-select .ts-control {
|
||||||
|
background: var(--nav-bg-dark) !important;
|
||||||
|
border-color: var(--border-dark) !important;
|
||||||
|
color: var(--text-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select.focus .ts-control {
|
||||||
|
border-color: var(--violet) !important;
|
||||||
|
box-shadow: 0 0 0 3px var(--violet-glow) !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input de recherche ── */
|
||||||
|
.form-tom-select .ts-control input {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 120px !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
font-family: 'DM Sans', sans-serif !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
color: var(--text-light) !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-tom-select .ts-control input {
|
||||||
|
color: var(--text-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-control input::placeholder {
|
||||||
|
color: var(--muted-light) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-tom-select .ts-control input::placeholder {
|
||||||
|
color: var(--muted-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tags sélectionnés ── */
|
||||||
|
.form-tom-select .ts-control .item {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
background: var(--violet) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
padding: 0.2rem 0.5rem !important;
|
||||||
|
font-size: 0.82rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
margin: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-control .item .remove {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||||
|
margin-left: 0.4rem !important;
|
||||||
|
padding-left: 0.4rem !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-control .item .remove:hover {
|
||||||
|
color: white !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dropdown ── */
|
||||||
|
.form-tom-select .ts-dropdown {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 1050 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
border: 1px solid var(--border-light) !important;
|
||||||
|
background: var(--nav-bg-light) !important;
|
||||||
|
font-family: 'DM Sans', sans-serif !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
margin-top: 4px !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-tom-select .ts-dropdown {
|
||||||
|
background: var(--nav-bg-dark) !important;
|
||||||
|
border-color: var(--border-dark) !important;
|
||||||
|
color: var(--text-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-dropdown .ts-dropdown-content {
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
max-height: 200px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-dropdown .option {
|
||||||
|
padding: 0.5rem 0.75rem !important;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
color: var(--text-light) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
transition: background var(--duration-fast), color var(--duration-fast) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-tom-select .ts-dropdown .option {
|
||||||
|
color: var(--text-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-dropdown .option:hover,
|
||||||
|
.form-tom-select .ts-dropdown .option.active {
|
||||||
|
background: var(--violet) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tom-select .ts-dropdown .option.selected {
|
||||||
|
opacity: 0.4 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
72
assets/styles/globals/navbutton.css
Normal file
72
assets/styles/globals/navbutton.css
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
nav .nav-link {
|
||||||
|
all: unset;
|
||||||
|
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.45rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none !important;
|
||||||
|
|
||||||
|
/* Dimensions */
|
||||||
|
padding: 0.55rem 1.1rem !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
border: 1px solid var(--border-light) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
|
||||||
|
/* Typographie */
|
||||||
|
font-family: 'Syne', sans-serif !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
letter-spacing: 0.03em !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
color: var(--muted-light) !important;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
color var(--duration-normal) ease,
|
||||||
|
background var(--duration-normal) ease,
|
||||||
|
border-color var(--duration-normal) ease,
|
||||||
|
box-shadow var(--duration-normal) ease,
|
||||||
|
transform var(--duration-fast) ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] nav .nav-link {
|
||||||
|
border-color: var(--border-dark) !important;
|
||||||
|
color: var(--muted-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover */
|
||||||
|
nav .nav-link:hover {
|
||||||
|
color: var(--violet) !important;
|
||||||
|
background: rgba(0, 0, 0, 0.05) !important;
|
||||||
|
border-color: rgba(0, 0, 0, 0.15) !important;
|
||||||
|
transform: translateY(-1px) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] nav .nav-link:hover {
|
||||||
|
color: var(--violet-light) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.07) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actif — fond violet léger + bordure violet */
|
||||||
|
nav .nav-link.active {
|
||||||
|
color: var(--violet) !important;
|
||||||
|
background: var(--pill-bg-light) !important;
|
||||||
|
border-color: rgba(124, 58, 237, 0.30) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] nav .nav-link.active {
|
||||||
|
color: var(--violet-light) !important;
|
||||||
|
background: var(--pill-bg-dark) !important;
|
||||||
|
border-color: rgba(167, 139, 250, 0.30) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actif + hover — glow subtil */
|
||||||
|
nav .nav-link.active:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.20) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pas de pseudo-élément soulignement */
|
||||||
|
nav .nav-link::before,
|
||||||
|
nav .nav-link::after { display: none !important; }
|
||||||
134
assets/styles/partials/flash.css
Normal file
134
assets/styles/partials/flash.css
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/* ================================================================
|
||||||
|
assets/styles/partials/flash.css
|
||||||
|
Flash messages — thème clair & sombre
|
||||||
|
================================================================ */
|
||||||
|
|
||||||
|
/* ── CONTENEUR ─────────────────────────────────────────────────── */
|
||||||
|
.flash-container {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(72px + 1.25rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 440px;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FLASH DE BASE ─────────────────────────────────────────────── */
|
||||||
|
.flash {
|
||||||
|
pointer-events: all;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
padding: 0.9rem 0.9rem 0.9rem 1.1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
backdrop-filter: blur(6px) saturate(1.8);
|
||||||
|
-webkit-backdrop-filter: blur(6px) saturate(1.8);
|
||||||
|
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
animation: flash-in 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COULEURS PAR TYPE — light ──────────────────────────────────── */
|
||||||
|
.flash-success {
|
||||||
|
background: rgba(16, 185, 129, 0.20);
|
||||||
|
border-color: rgba(16, 185, 129, 0.50);
|
||||||
|
color: #064e3b;
|
||||||
|
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(16,185,129,0.20);
|
||||||
|
}
|
||||||
|
.flash-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.20);
|
||||||
|
border-color: rgba(239, 68, 68, 0.50);
|
||||||
|
color: #7f1d1d;
|
||||||
|
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(239,68,68,0.20);
|
||||||
|
}
|
||||||
|
.flash-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.20);
|
||||||
|
border-color: rgba(245, 158, 11, 0.50);
|
||||||
|
color: #78350f;
|
||||||
|
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(245,158,11,0.20);
|
||||||
|
}
|
||||||
|
.flash-info {
|
||||||
|
background: rgba(124, 58, 237, 0.18);
|
||||||
|
border-color: rgba(124, 58, 237, 0.50);
|
||||||
|
color: #2e1065;
|
||||||
|
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(124,58,237,0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COULEURS PAR TYPE — dark ───────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .flash-success {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
border-color: rgba(16, 185, 129, 0.45);
|
||||||
|
color: #6ee7b7;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(16,185,129,0.18);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .flash-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
border-color: rgba(239, 68, 68, 0.45);
|
||||||
|
color: #fca5a5;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(239,68,68,0.18);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .flash-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
border-color: rgba(245, 158, 11, 0.45);
|
||||||
|
color: #fcd34d;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(245,158,11,0.18);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .flash-info {
|
||||||
|
background: rgba(124, 58, 237, 0.20);
|
||||||
|
border-color: rgba(167, 139, 250, 0.45);
|
||||||
|
color: #c4b5fd;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(124,58,237,0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ANIMATION ─────────────────────────────────────────────────── */
|
||||||
|
@keyframes flash-in {
|
||||||
|
from { opacity: 0; transform: translateY(-40px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TEXTE ─────────────────────────────────────────────────────── */
|
||||||
|
.flash-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── BOUTON FERMER ─────────────────────────────────────────────── */
|
||||||
|
.flash-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
min-width: 26px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--duration-fast), background var(--duration-fast);
|
||||||
|
}
|
||||||
|
.flash-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MOBILE ────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.flash-container { width: calc(100vw - 2rem); }
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
Styles du header — thème clair & sombre
|
Styles du header — thème clair & sombre
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|
||||||
|
@import '../globals/navbutton.css';
|
||||||
|
|
||||||
/* ── HEADER ────────────────────────────────────────────────────── */
|
/* ── HEADER ────────────────────────────────────────────────────── */
|
||||||
header {
|
header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -73,80 +75,6 @@ header nav {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── LIENS DE NAVIGATION ───────────────────────────────────────── */
|
|
||||||
header nav .nav-link {
|
|
||||||
all: unset;
|
|
||||||
|
|
||||||
display: inline-flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
gap: 0.45rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none !important;
|
|
||||||
|
|
||||||
/* Dimensions */
|
|
||||||
padding: 0.55rem 1.1rem !important;
|
|
||||||
border-radius: 10px !important;
|
|
||||||
border: 1px solid var(--border-light) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
|
|
||||||
/* Typographie */
|
|
||||||
font-family: 'Syne', sans-serif !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
letter-spacing: 0.03em !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
color: var(--muted-light) !important;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
color var(--duration-normal) ease,
|
|
||||||
background var(--duration-normal) ease,
|
|
||||||
border-color var(--duration-normal) ease,
|
|
||||||
box-shadow var(--duration-normal) ease,
|
|
||||||
transform var(--duration-fast) ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] header nav .nav-link {
|
|
||||||
border-color: var(--border-dark) !important;
|
|
||||||
color: var(--muted-dark) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover */
|
|
||||||
header nav .nav-link:hover {
|
|
||||||
color: var(--violet) !important;
|
|
||||||
background: rgba(0, 0, 0, 0.05) !important;
|
|
||||||
border-color: rgba(0, 0, 0, 0.15) !important;
|
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] header nav .nav-link:hover {
|
|
||||||
color: var(--violet-light) !important;
|
|
||||||
background: rgba(255, 255, 255, 0.07) !important;
|
|
||||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actif — fond violet léger + bordure violet */
|
|
||||||
header nav .nav-link.active {
|
|
||||||
color: var(--violet) !important;
|
|
||||||
background: var(--pill-bg-light) !important;
|
|
||||||
border-color: rgba(124, 58, 237, 0.30) !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] header nav .nav-link.active {
|
|
||||||
color: var(--violet-light) !important;
|
|
||||||
background: var(--pill-bg-dark) !important;
|
|
||||||
border-color: rgba(167, 139, 250, 0.30) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actif + hover — glow subtil */
|
|
||||||
header nav .nav-link.active:hover {
|
|
||||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.20) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pas de pseudo-élément soulignement */
|
|
||||||
header nav .nav-link::before,
|
|
||||||
header nav .nav-link::after { display: none !important; }
|
|
||||||
|
|
||||||
/* ── LIENS DROPDOWN ─────────── */
|
/* ── LIENS DROPDOWN ─────────── */
|
||||||
header .header-dropdown .dropdown-link {
|
header .header-dropdown .dropdown-link {
|
||||||
all: unset;
|
all: unset;
|
||||||
|
|
@ -387,9 +315,54 @@ header .header-dropdown .dropdown-link::after { display: none !important; }
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PHOTO DE PROFIL ───────────────────────────────────────────── */
|
||||||
|
.profile-pic {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover; /* crop centré, pas d'étirement */
|
||||||
|
object-position: center;
|
||||||
|
border: 1.5px solid var(--border-light);
|
||||||
|
background: rgba(0, 0, 0, 0.06); /* fallback si l'image met du temps */
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
transition: border-color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .profile-pic {
|
||||||
|
border-color: var(--border-dark);
|
||||||
|
}
|
||||||
|
/* Légère surbrillance au hover du bouton parent */
|
||||||
|
.login-btn:hover .profile-pic {
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── MOBILE ────────────────────────────────────────────────────── */
|
/* ── MOBILE ────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
header nav { display: none !important; }
|
header nav { display: none !important; }
|
||||||
header { padding: 0 1rem !important; }
|
header { padding: 0 1rem !important; }
|
||||||
.header-left { gap: 0; }
|
.header-left { gap: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── MESSAGE D'ERREUR DANS LE DROPDOWN ─────────────────────────── */
|
||||||
|
.login-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error .alert-danger {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 0.813rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .login-error .alert-danger {
|
||||||
|
border-color: #ff6b7a;
|
||||||
|
background: rgba(255, 107, 122, 0.15);
|
||||||
|
color: #ff8a9b;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
body{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
1
assets/styles/vitrine/index.css
Normal file
1
assets/styles/vitrine/index.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -42,7 +42,8 @@
|
||||||
"symfony/web-link": "7.4.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/yaml": "7.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0",
|
||||||
|
"ext-zip": "*"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
security:
|
security:
|
||||||
|
# Hiérarchie des rôles - les rôles enfants héritent des rôles parents
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_ADMIN: [ROLE_USER]
|
||||||
|
ROLE_MODERATOR: [ROLE_USER]
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
|
|
@ -10,25 +15,47 @@ security:
|
||||||
entity:
|
entity:
|
||||||
class: App\Entity\User
|
class: App\Entity\User
|
||||||
property: pseudo
|
property: pseudo
|
||||||
|
api_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Entity\User
|
||||||
|
property: pseudo
|
||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
# Ensure dev tools and static assets are always allowed
|
# Ensure dev tools and static assets are always allowed
|
||||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||||
security: false
|
security: false
|
||||||
|
api:
|
||||||
|
pattern: ^/api
|
||||||
|
provider: app_user_provider
|
||||||
|
context: app_security
|
||||||
|
stateless: false
|
||||||
|
lazy: false
|
||||||
|
# Charger la session du context partagé automatiquement
|
||||||
|
# Cela permet à Symfony d'authentifier les utilisateurs connectés
|
||||||
|
# http_basic agit ici comme un fallback pour accepter les sessions
|
||||||
|
http_basic: ~
|
||||||
|
# Authenticateur pour gérer les tokens API (Bearer)
|
||||||
|
# Si pas de Bearer token, la session du context est utilisée
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\ApiBearerTokenAuthenticator
|
||||||
main:
|
main:
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
context: app_security # Utiliser le même contexte
|
||||||
form_login:
|
form_login:
|
||||||
login_path: app_login # route GET (affichage formulaire)
|
login_path: app_login # route GET (affichage formulaire)
|
||||||
check_path: app_login # route POST (traitement)
|
check_path: app_login # route POST (traitement)
|
||||||
username_parameter: _username
|
username_parameter: _username
|
||||||
password_parameter: _password
|
password_parameter: _password
|
||||||
default_target_path: app_accueil
|
default_target_path: app_accueil
|
||||||
|
target_path_parameter: _target_path
|
||||||
|
failure_path: /
|
||||||
|
use_referer: true
|
||||||
enable_csrf: true
|
enable_csrf: true
|
||||||
|
failure_handler: App\Security\LoginFailureHandler
|
||||||
|
success_handler: App\Security\LoginSuccessHandler
|
||||||
logout:
|
logout:
|
||||||
path: app_logout
|
path: app_logout
|
||||||
# where to redirect after logout
|
|
||||||
# target: app_any_route
|
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
# Activate different ways to authenticate:
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
# https://symfony.com/doc/current/security.html#the-firewall
|
||||||
|
|
@ -38,8 +65,8 @@ security:
|
||||||
|
|
||||||
# Note: Only the *first* matching rule is applied
|
# Note: Only the *first* matching rule is applied
|
||||||
access_control:
|
access_control:
|
||||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
- { path: ^/administration, roles: ROLE_ADMIN }
|
||||||
# - { path: ^/profile, roles: ROLE_USER }
|
- { path: ^/profile, roles: ROLE_USER }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@
|
||||||
|
|
||||||
# To list all registered routes, run the following command:
|
# To list all registered routes, run the following command:
|
||||||
# bin/console debug:router
|
# bin/console debug:router
|
||||||
|
api_v1:
|
||||||
|
resource: ../src/Controller/Api/V1/
|
||||||
|
type: attribute
|
||||||
|
prefix: /api/v1
|
||||||
|
|
||||||
controllers:
|
controllers:
|
||||||
resource: routing.controllers
|
resource: ../src/Controller/
|
||||||
|
type: attribute
|
||||||
|
exclude:
|
||||||
|
- ../src/Controller/Api/
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@
|
||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# 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
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
app.upload_dir: '%kernel.project_dir%/public/uploads'
|
||||||
|
app.tmp_dir: '%kernel.project_dir%/var/tmp'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
_defaults:
|
_defaults:
|
||||||
autowire: true # Automatically injects dependencies in your services.
|
autowire: true # Automatically injects dependencies in your services.
|
||||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||||
|
bind:
|
||||||
|
$uploadDir: '%app.upload_dir%' # bind the $uploadDir argument to the app.upload_dir parameter
|
||||||
|
$tmpDir: '%app.tmp_dir%' # bind the $tmpDir argument to the app.tmp_dir parameter
|
||||||
|
|
||||||
# makes classes in src/ available to be used as services
|
# makes classes in src/ available to be used as services
|
||||||
# this creates a service per class whose id is the fully-qualified class name
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
|
|
@ -21,3 +26,4 @@ services:
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|
||||||
|
|
|
||||||
825
document/DOCUMENTATION_API.md
Normal file
825
document/DOCUMENTATION_API.md
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
# 📚 Documentation Complète API mcV3
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Architecture générale](#architecture-générale)
|
||||||
|
2. [Authentification](#authentification)
|
||||||
|
3. [Endpoints API](#endpoints-api)
|
||||||
|
4. [Gestion des clés API](#gestion-des-clés-api)
|
||||||
|
5. [Rôles et permissions](#rôles-et-permissions)
|
||||||
|
6. [Codes HTTP et erreurs](#codes-http-et-erreurs)
|
||||||
|
7. [Intégrations](#intégrations)
|
||||||
|
8. [Configuration avancée](#configuration-avancée)
|
||||||
|
9. [FAQ et dépannage](#faq-et-dépannage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture générale
|
||||||
|
|
||||||
|
### Vue d'ensemble
|
||||||
|
|
||||||
|
Votre API utilise une **authentification hybride** combinant deux modes:
|
||||||
|
|
||||||
|
1. **Token API (Bearer Token)** - Pour applications externes/services
|
||||||
|
2. **Session utilisateur** - Pour utilisateurs connectés au site web
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Requête /api/* │
|
||||||
|
└────────────────┬────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────┴──────────┐
|
||||||
|
│ │
|
||||||
|
┌────▼─────┐ ┌───────▼──────┐
|
||||||
|
│ Bearer │ │ Session │
|
||||||
|
│ Token? │ │ Active? │
|
||||||
|
└────┬─────┘ └───────┬──────┘
|
||||||
|
│ │
|
||||||
|
┌────▼──────────────────▼────┐
|
||||||
|
│ ApiTokenHandler OR │
|
||||||
|
│ Session Storage │
|
||||||
|
└────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌────▼──────────────────────┐
|
||||||
|
│ Load User + Roles │
|
||||||
|
└────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌────▼──────────────────────┐
|
||||||
|
│ Verify @IsGranted() │
|
||||||
|
└────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
✅ 200 OK ou ❌ 401/403
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux de sécurité
|
||||||
|
|
||||||
|
1. **Firewall `/api`** - Accepte les requêtes `/api/*`
|
||||||
|
2. **ApiBearerTokenAuthenticator** - Vérifie les Bearer tokens
|
||||||
|
3. **http_basic** - Charge la session du context partagé
|
||||||
|
4. **Contrôles d'accès** - Vérifie `@IsGranted()` sur les routes
|
||||||
|
5. **ApiExceptionListener** - Convertit les erreurs en JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
### Mode 1: Bearer Token (Clé API)
|
||||||
|
|
||||||
|
#### Génération du token
|
||||||
|
|
||||||
|
**Via CLI:**
|
||||||
|
```bash
|
||||||
|
php bin/console app:api-key:create pseudo_utilisateur "Nom de l'application"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le token est généré automatiquement (64 caractères hexadécimaux, cryptographiquement sécurisé).
|
||||||
|
|
||||||
|
**Via API (utilisateur connecté):**
|
||||||
|
```http
|
||||||
|
POST /api/api-keys
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer existing_token
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Application mobile",
|
||||||
|
"expiresAt": "2027-04-03T23:59:59Z" // optionnel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Utilisation du token
|
||||||
|
|
||||||
|
Le token doit être envoyé dans l'en-tête `Authorization`:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/profile
|
||||||
|
Authorization: Bearer abc123def456...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Caractéristiques
|
||||||
|
|
||||||
|
- ✅ Unique par clé API
|
||||||
|
- ✅ Peut avoir une date d'expiration
|
||||||
|
- ✅ Peut être désactivé/révoqué
|
||||||
|
- ✅ Stateless (pas de session serveur)
|
||||||
|
- ✅ Audit (tracking du dernier accès)
|
||||||
|
|
||||||
|
### Mode 2: Session utilisateur
|
||||||
|
|
||||||
|
#### Établir une session
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /login
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
_username=john&_password=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Un cookie `PHPSESSID` est créé et stocké.
|
||||||
|
|
||||||
|
#### Utiliser la session
|
||||||
|
|
||||||
|
Tous les cookies sont envoyés automatiquement par le navigateur. Via cURL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST http://localhost:8000/login \
|
||||||
|
-d "_username=john&_password=secret"
|
||||||
|
|
||||||
|
curl -b cookies.txt http://localhost:8000/api/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Caractéristiques
|
||||||
|
|
||||||
|
- ✅ Basée sur les cookies
|
||||||
|
- ✅ Automatique dans le navigateur
|
||||||
|
- ✅ Partage avec le firewall `main`
|
||||||
|
- ✅ Timeout configurable
|
||||||
|
- ✅ Plus sécurisée pour les applications web
|
||||||
|
|
||||||
|
### Détection du mode d'authentification
|
||||||
|
|
||||||
|
La réponse `/api/profile` indique le mode utilisé:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"pseudo": "john",
|
||||||
|
"roles": ["ROLE_USER"],
|
||||||
|
"authenticatedVia": "api_key" // ou "session"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints API
|
||||||
|
|
||||||
|
### GET /api/health
|
||||||
|
|
||||||
|
**Authentification**: ❌ Non requise
|
||||||
|
|
||||||
|
Vérifier l'état de l'API.
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2026-04-03T10:30:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/profile
|
||||||
|
|
||||||
|
**Authentification**: ✅ Requise (Bearer token ou session)
|
||||||
|
|
||||||
|
Récupérer le profil de l'utilisateur authentifié.
|
||||||
|
|
||||||
|
**Exemple avec Bearer token:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/profile \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"pseudo": "john",
|
||||||
|
"roles": ["ROLE_USER", "ROLE_ADMIN"],
|
||||||
|
"authenticatedVia": "api_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erreurs possibles:**
|
||||||
|
- `401 Unauthorized` - Token invalide/expiré ou pas de session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/api-keys
|
||||||
|
|
||||||
|
**Authentification**: ✅ Requise (Bearer token ou session)
|
||||||
|
|
||||||
|
Lister les clés API.
|
||||||
|
|
||||||
|
- **ROLE_USER**: Voit uniquement SES propres clés
|
||||||
|
- **ROLE_ADMIN**: Voit TOUTES les clés du système
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKeys": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Mobile App",
|
||||||
|
"token": "a1b2c3d4e5...", // masqué pour sécurité
|
||||||
|
"createdAt": "2026-04-01T10:00:00+00:00",
|
||||||
|
"lastUsedAt": "2026-04-03T15:30:00+00:00",
|
||||||
|
"expiresAt": "2026-12-31T23:59:59+00:00",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/api-keys
|
||||||
|
|
||||||
|
**Authentification**: ✅ Requise (Bearer token ou session)
|
||||||
|
|
||||||
|
Créer une nouvelle clé API.
|
||||||
|
|
||||||
|
**Body (JSON):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Nouvelle application",
|
||||||
|
"expiresAt": "2027-04-03T23:59:59Z" // optionnel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer your_token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "API Desktop",
|
||||||
|
"expiresAt": "2026-12-31T23:59:59Z"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "API Desktop",
|
||||||
|
"token": "abc123def456...xyz", // Complet à la création uniquement!
|
||||||
|
"createdAt": "2026-04-03T16:45:00+00:00",
|
||||||
|
"message": "Copier le token en lieu sûr, il ne sera plus visible ultérieurement"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT**: Le token complet n'est affiché qu'à la création. Stockez-le immédiatement.
|
||||||
|
|
||||||
|
**Erreurs possibles:**
|
||||||
|
- `400 Bad Request` - Paramètre "name" manquant
|
||||||
|
- `401 Unauthorized` - Authentification invalide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/api-keys/{id}
|
||||||
|
|
||||||
|
**Authentification**: ✅ Requise (Bearer token ou session)
|
||||||
|
|
||||||
|
Supprimer une clé API.
|
||||||
|
|
||||||
|
- **ROLE_USER**: Peut supprimer uniquement ses propres clés
|
||||||
|
- **ROLE_ADMIN**: Peut supprimer n'importe quelle clé
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8000/api/api-keys/1 \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "API key deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erreurs possibles:**
|
||||||
|
- `403 Forbidden` - Essai de supprimer la clé d'un autre utilisateur
|
||||||
|
- `404 Not Found` - Clé inexistante
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PATCH /api/api-keys/{id}/deactivate
|
||||||
|
|
||||||
|
**Authentification**: ✅ Requise (Bearer token ou session)
|
||||||
|
|
||||||
|
Désactiver une clé API sans la supprimer.
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:8000/api/api-keys/1/deactivate \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "API key deactivated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestion des clés API
|
||||||
|
|
||||||
|
### Cycle de vie d'une clé
|
||||||
|
|
||||||
|
```
|
||||||
|
Création
|
||||||
|
│
|
||||||
|
├─→ Actif et utilisable
|
||||||
|
│
|
||||||
|
├─→ Peut être désactivé (sans suppression)
|
||||||
|
│
|
||||||
|
├─→ Peut avoir une expiration automatique
|
||||||
|
│
|
||||||
|
└─→ Peut être supprimé définitivement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bonnes pratiques
|
||||||
|
|
||||||
|
1. **Créer des clés par application** - Une clé par service/app
|
||||||
|
2. **Définir une expiration** - Rotation annuelle recommandée
|
||||||
|
3. **Révoquer régulièrement** - Supprimer les vieilles clés
|
||||||
|
4. **Ne jamais partager** - Chaque clé est personnelle
|
||||||
|
5. **Monitorer l'accès** - Vérifier `lastUsedAt`
|
||||||
|
|
||||||
|
### Exemple: Rotation de clés
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Créer une nouvelle clé
|
||||||
|
curl -X POST http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer old_token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Mobile App v2"}'
|
||||||
|
|
||||||
|
# 2. Mettre à jour l'application avec la nouvelle clé
|
||||||
|
# ... déployer avec new_token ...
|
||||||
|
|
||||||
|
# 3. Désactiver l'ancienne clé
|
||||||
|
curl -X PATCH http://localhost:8000/api/api-keys/1/deactivate \
|
||||||
|
-H "Authorization: Bearer new_token"
|
||||||
|
|
||||||
|
# 4. (Optionnel) Supprimer après confirmation
|
||||||
|
curl -X DELETE http://localhost:8000/api/api-keys/1 \
|
||||||
|
-H "Authorization: Bearer new_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rôles et permissions
|
||||||
|
|
||||||
|
### Hiérarchie de rôles
|
||||||
|
|
||||||
|
```
|
||||||
|
ROLE_ADMIN
|
||||||
|
└── ROLE_USER
|
||||||
|
|
||||||
|
ROLE_MODERATOR
|
||||||
|
└── ROLE_USER
|
||||||
|
|
||||||
|
ROLE_USER (base)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions par rôle
|
||||||
|
|
||||||
|
| Action | ROLE_USER | ROLE_ADMIN |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| Voir son profil | ✅ | ✅ |
|
||||||
|
| Voir ses clés | ✅ | ✅ |
|
||||||
|
| Voir les clés d'autres | ❌ | ✅ |
|
||||||
|
| Créer ses clés | ✅ | ✅ |
|
||||||
|
| Supprimer ses clés | ✅ | ✅ |
|
||||||
|
| Supprimer les clés d'autres | ❌ | ✅ |
|
||||||
|
| Accéder à /api/* | ✅ | ✅ |
|
||||||
|
| Accéder à /administration | ❌ | ✅ |
|
||||||
|
|
||||||
|
### Implémentation dans le code
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Protéger une route par rôle
|
||||||
|
#[Route('/api/mon-endpoint')]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function monEndpoint(): JsonResponse { }
|
||||||
|
|
||||||
|
// Logique conditionnelle selon le rôle
|
||||||
|
if ($this->isGranted('ROLE_ADMIN')) {
|
||||||
|
// Admin seulement
|
||||||
|
return $this->json($allData);
|
||||||
|
}
|
||||||
|
return $this->json($userData);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codes HTTP et erreurs
|
||||||
|
|
||||||
|
### Codes de succès
|
||||||
|
|
||||||
|
| Code | Signification | Utilisation |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| 200 | OK | Requête réussie (GET, POST, PATCH, DELETE) |
|
||||||
|
| 201 | Created | Ressource créée (POST) |
|
||||||
|
| 204 | No Content | Succès sans contenu (rare) |
|
||||||
|
|
||||||
|
### Codes d'erreur client (4xx)
|
||||||
|
|
||||||
|
#### 400 Bad Request
|
||||||
|
Requête invalide (paramètres manquants, JSON malformé).
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Bad Request",
|
||||||
|
"message": "name is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Vérifier le format JSON
|
||||||
|
- Ajouter les paramètres requis
|
||||||
|
|
||||||
|
#### 401 Unauthorized
|
||||||
|
Non authentifié ou authentification invalide.
|
||||||
|
|
||||||
|
**Cas courants:**
|
||||||
|
- Token manquant: `curl http://localhost:8000/api/profile`
|
||||||
|
- Token invalide: `Authorization: Bearer wrong_token`
|
||||||
|
- Token expiré: Token dont la date d'expiration est passée
|
||||||
|
- Session expirée: Cookies invalides/écoulés
|
||||||
|
|
||||||
|
**Réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": "Authentication required. Please provide a valid API token or be logged in."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Ajouter le header `Authorization: Bearer YOUR_TOKEN`
|
||||||
|
- Renouveler le token expiré
|
||||||
|
- Se reconnecter (pour session)
|
||||||
|
|
||||||
|
#### 403 Forbidden
|
||||||
|
Authentifié mais permissions insuffisantes.
|
||||||
|
|
||||||
|
**Cas courants:**
|
||||||
|
- ROLE_USER essayant de supprimer la clé d'un autre
|
||||||
|
- Accès à un endpoint réservé aux admins
|
||||||
|
|
||||||
|
**Réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Forbidden",
|
||||||
|
"message": "You do not have permission to access this resource"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Utiliser un compte admin si nécessaire
|
||||||
|
- Vérifier les droits requis
|
||||||
|
|
||||||
|
#### 404 Not Found
|
||||||
|
Ressource inexistante.
|
||||||
|
|
||||||
|
**Cas courants:**
|
||||||
|
- Clé API inexistante: `DELETE /api/api-keys/99999`
|
||||||
|
- Endpoint invalide: `GET /api/nonexistent`
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Vérifier l'ID de la ressource
|
||||||
|
- Vérifier le chemin de la route
|
||||||
|
|
||||||
|
### Codes d'erreur serveur (5xx)
|
||||||
|
|
||||||
|
#### 500 Internal Server Error
|
||||||
|
Erreur serveur générique.
|
||||||
|
|
||||||
|
**Cause:** Bug ou exception non gérée.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Vérifier les logs: `tail -f var/log/dev.log`
|
||||||
|
- Contacter le développeur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intégrations
|
||||||
|
|
||||||
|
### JavaScript / Fetch API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Configuration
|
||||||
|
const API_BASE_URL = 'http://localhost:8000';
|
||||||
|
const API_TOKEN = 'your_api_token';
|
||||||
|
|
||||||
|
// Helper pour les appels API
|
||||||
|
async function apiCall(endpoint, options = {}) {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`;
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(`${response.status}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation
|
||||||
|
async function getProfile() {
|
||||||
|
try {
|
||||||
|
const profile = await apiCall('/api/profile');
|
||||||
|
console.log('Profil:', profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApiKey(name) {
|
||||||
|
try {
|
||||||
|
const key = await apiCall('/api/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
console.log('Clé créée:', key.token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appels
|
||||||
|
getProfile();
|
||||||
|
createApiKey('Mon App');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python / Requests
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
def __init__(self, base_url, api_token):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.api_token = api_token
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'Authorization': f'Bearer {api_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
def request(self, method, endpoint, data=None):
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
try:
|
||||||
|
if method == 'GET':
|
||||||
|
response = self.session.get(url)
|
||||||
|
elif method == 'POST':
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
elif method == 'DELETE':
|
||||||
|
response = self.session.delete(url)
|
||||||
|
elif method == 'PATCH':
|
||||||
|
response = self.session.patch(url, json=data)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Erreur: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_profile(self):
|
||||||
|
return self.request('GET', '/api/profile')
|
||||||
|
|
||||||
|
def list_keys(self):
|
||||||
|
return self.request('GET', '/api/api-keys')
|
||||||
|
|
||||||
|
def create_key(self, name, expires_at=None):
|
||||||
|
data = {'name': name}
|
||||||
|
if expires_at:
|
||||||
|
data['expiresAt'] = expires_at
|
||||||
|
return self.request('POST', '/api/api-keys', data)
|
||||||
|
|
||||||
|
# Utilisation
|
||||||
|
client = ApiClient('http://localhost:8000', 'your_api_token')
|
||||||
|
|
||||||
|
profile = client.get_profile()
|
||||||
|
print(f"Profil: {profile}")
|
||||||
|
|
||||||
|
keys = client.list_keys()
|
||||||
|
print(f"Clés: {keys}")
|
||||||
|
|
||||||
|
new_key = client.create_key('Python App')
|
||||||
|
print(f"Nouvelle clé: {new_key['token']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP / cURL
|
||||||
|
|
||||||
|
```php
|
||||||
|
class ApiClient {
|
||||||
|
private $baseUrl;
|
||||||
|
private $apiToken;
|
||||||
|
|
||||||
|
public function __construct($baseUrl, $apiToken) {
|
||||||
|
$this->baseUrl = rtrim($baseUrl, '/');
|
||||||
|
$this->apiToken = $apiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request($method, $endpoint, $data = null) {
|
||||||
|
$url = $this->baseUrl . $endpoint;
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => [
|
||||||
|
"Authorization: Bearer {$this->apiToken}",
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($data && in_array($method, ['POST', 'PATCH'])) {
|
||||||
|
$options['http']['content'] = json_encode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create($options);
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
return ['error' => 'Erreur de connexion'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode($response, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProfile() {
|
||||||
|
return $this->request('GET', '/api/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listKeys() {
|
||||||
|
return $this->request('GET', '/api/api-keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createKey($name, $expiresAt = null) {
|
||||||
|
$data = ['name' => $name];
|
||||||
|
if ($expiresAt) {
|
||||||
|
$data['expiresAt'] = $expiresAt;
|
||||||
|
}
|
||||||
|
return $this->request('POST', '/api/api-keys', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation
|
||||||
|
$client = new ApiClient('http://localhost:8000', 'your_api_token');
|
||||||
|
|
||||||
|
$profile = $client->getProfile();
|
||||||
|
var_dump($profile);
|
||||||
|
|
||||||
|
$keys = $client->listKeys();
|
||||||
|
var_dump($keys);
|
||||||
|
|
||||||
|
$newKey = $client->createKey('PHP App');
|
||||||
|
echo "Nouvelle clé: " . $newKey['token'];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration avancée
|
||||||
|
|
||||||
|
### Ajouter des endpoints personnalisés
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Dans src/Controller/ApiController.php
|
||||||
|
|
||||||
|
#[Route('/api/custom-endpoint')]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function customEndpoint(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'message' => 'Données personnalisées',
|
||||||
|
'user' => $user->getPseudo(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtrer par rôle dans un endpoint
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/admin-data')]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function adminData(): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||||
|
return $this->json(
|
||||||
|
['error' => 'Accès admin requis'],
|
||||||
|
JsonResponse::HTTP_FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'adminData' => 'Secret data',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valider les requêtes
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/create-item', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function createItem(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
// Valider les paramètres requis
|
||||||
|
if (empty($data['name'])) {
|
||||||
|
return $this->json(
|
||||||
|
['error' => 'name is required'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter les données...
|
||||||
|
|
||||||
|
return $this->json(['id' => 1, 'name' => $data['name']]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ et dépannage
|
||||||
|
|
||||||
|
### Q: Erreur "Invalid or expired API token"
|
||||||
|
**R:** Le token est invalide ou expiré.
|
||||||
|
- Vérifier que le token est correct
|
||||||
|
- Créer une nouvelle clé: `php bin/console app:api-key:create`
|
||||||
|
- Vérifier l'expiration
|
||||||
|
|
||||||
|
### Q: Comment mettre à jour ma clé?
|
||||||
|
**R:** Les clés ne peuvent pas être mises à jour. Solutions:
|
||||||
|
- Créer une nouvelle clé avec une date d'expiration plus lointaine
|
||||||
|
- Révoquer l'ancienne clé
|
||||||
|
|
||||||
|
### Q: Puis-je utiliser la même clé pour plusieurs applications?
|
||||||
|
**R:** Techniquement oui, mais **déconseillé** pour des raisons de sécurité:
|
||||||
|
- Une compromission affecte toutes les applications
|
||||||
|
- Impossible de révoquer une seule application
|
||||||
|
- Créer une clé par application
|
||||||
|
|
||||||
|
### Q: Comment changer l'expiration d'une clé?
|
||||||
|
**R:** Ce n'est pas possible directement. Options:
|
||||||
|
1. Créer une nouvelle clé avec expiration souhaitée
|
||||||
|
2. Révoquer l'ancienne
|
||||||
|
3. Mettre à jour l'application
|
||||||
|
|
||||||
|
### Q: L'API fonctionne-t-elle avec HTTPS?
|
||||||
|
**R:** Oui, recommandé en production. Assurez-vous que:
|
||||||
|
- Les certificats sont valides
|
||||||
|
- Les URLs sont en HTTPS
|
||||||
|
- Les cookies ont le flag `Secure`
|
||||||
|
|
||||||
|
### Q: Puis-je accéder à l'API depuis un autre domaine (CORS)?
|
||||||
|
**R:** CORS n'est pas configuré par défaut. Pour l'activer:
|
||||||
|
1. Installer bundle CORS: `composer require nelmio/cors-bundle`
|
||||||
|
2. Configurer les domaines autorisés
|
||||||
|
|
||||||
|
### Q: Comment monitorer l'utilisation de l'API?
|
||||||
|
**R:** Vérifier:
|
||||||
|
- `lastUsedAt` dans GET `/api/api-keys`
|
||||||
|
- Les logs: `var/log/dev.log`
|
||||||
|
- Implémenter un logging personnalisé
|
||||||
|
|
||||||
|
### Q: Erreur "403 Forbidden"
|
||||||
|
**R:** Vous n'avez pas les permissions. Vérifier:
|
||||||
|
- Votre rôle (GET `/api/profile`)
|
||||||
|
- Les droits requis (@IsGranted)
|
||||||
|
- Si vous essayez de modifier les données d'un autre
|
||||||
|
|
||||||
|
### Q: Comment réinitialiser toutes mes clés?
|
||||||
|
**R:**
|
||||||
|
1. Lister les clés: `GET /api/api-keys`
|
||||||
|
2. Supprimer une par une: `DELETE /api/api-keys/{id}`
|
||||||
|
3. Créer de nouvelles clés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation à jour: 2026-04-03**
|
||||||
|
|
||||||
207
document/GUIDE_USER_RAPIDE_API.md
Normal file
207
document/GUIDE_USER_RAPIDE_API.md
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
# 🚀 Guide rapide API - Démarrage en 5 minutes
|
||||||
|
|
||||||
|
## 1️⃣ Créer une clé API
|
||||||
|
|
||||||
|
### Via ligne de commande
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console app:api-key:create votre_pseudo "Nom de votre application"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```bash
|
||||||
|
php bin/console app:api-key:create john "Mon App Mobile"
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous recevrez un **token unique** - conservez-le en lieu sûr!
|
||||||
|
|
||||||
|
### Via l'API (pour utilisateurs connectés)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Nom de votre clé"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Accéder à l'API
|
||||||
|
|
||||||
|
### Option A: Avec une clé API (recommandé pour applications externes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/profile \
|
||||||
|
-H "Authorization: Bearer your_api_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple de réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"pseudo": "john",
|
||||||
|
"roles": ["ROLE_USER"],
|
||||||
|
"authenticatedVia": "api_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Via session (pour utilisateurs connectés au site)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Après connexion au site web
|
||||||
|
curl http://localhost:8000/api/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
La session est automatiquement utilisée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ Endpoints disponibles
|
||||||
|
|
||||||
|
| Méthode | Route | Authentification | Description |
|
||||||
|
|---------|-------|------------------|-------------|
|
||||||
|
| `GET` | `/api/health` | ❌ Non | Vérifier l'état de l'API |
|
||||||
|
| `GET` | `/api/profile` | ✅ Oui | Votre profil utilisateur |
|
||||||
|
| `GET` | `/api/api-keys` | ✅ Oui | Vos clés API / toutes (admin) |
|
||||||
|
| `POST` | `/api/api-keys` | ✅ Oui | Créer une nouvelle clé |
|
||||||
|
| `DELETE` | `/api/api-keys/{id}` | ✅ Oui | Supprimer une clé |
|
||||||
|
| `PATCH` | `/api/api-keys/{id}/deactivate` | ✅ Oui | Désactiver une clé |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4️⃣ Exemples pratiques
|
||||||
|
|
||||||
|
### Vérifier l'état de l'API
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Récupérer votre profil
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/profile \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lister vos clés API
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer une nouvelle clé
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/api-keys \
|
||||||
|
-H "Authorization: Bearer your_token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Nouvelle clé", "expiresAt": "2027-04-03T00:00:00Z"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supprimer une clé
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8000/api/api-keys/1 \
|
||||||
|
-H "Authorization: Bearer your_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5️⃣ Utilisation dans votre application
|
||||||
|
|
||||||
|
### JavaScript / Fetch
|
||||||
|
```javascript
|
||||||
|
const apiToken = 'your_api_token';
|
||||||
|
|
||||||
|
async function getProfile() {
|
||||||
|
const response = await fetch('http://localhost:8000/api/profile', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile().then(profile => console.log(profile));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python / Requests
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
api_token = 'your_api_token'
|
||||||
|
headers = {'Authorization': f'Bearer {api_token}'}
|
||||||
|
|
||||||
|
response = requests.get('http://localhost:8000/api/profile', headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP / cURL
|
||||||
|
```php
|
||||||
|
$token = 'your_api_token';
|
||||||
|
$url = 'http://localhost:8000/api/profile';
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "Authorization: Bearer $token"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = stream_context_create($options);
|
||||||
|
$response = file_get_contents($url, false, $context);
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
print_r($data);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6️⃣ Gestion des erreurs courants
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
- **Cause**: Token invalide ou expiré, pas d'authentification
|
||||||
|
- **Solution**: Vérifier votre token, le renouveler si nécessaire
|
||||||
|
|
||||||
|
### 403 Forbidden
|
||||||
|
- **Cause**: Authentifié mais permissions insuffisantes
|
||||||
|
- **Solution**: Vérifier que vous avez les droits nécessaires
|
||||||
|
|
||||||
|
### 404 Not Found
|
||||||
|
- **Cause**: Ressource inexistante (ex: clé API inexistante)
|
||||||
|
- **Solution**: Vérifier l'ID de la ressource
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7️⃣ Sécurité
|
||||||
|
|
||||||
|
✅ **À FAIRE:**
|
||||||
|
- Garder votre token secret
|
||||||
|
- Utiliser HTTPS en production
|
||||||
|
- Créer de nouvelles clés régulièrement
|
||||||
|
- Révoquer les clés inutilisées
|
||||||
|
|
||||||
|
❌ **À NE PAS FAIRE:**
|
||||||
|
- Partager votre token
|
||||||
|
- Mettre votre token en version control (git)
|
||||||
|
- Utiliser le même token pour tout
|
||||||
|
- Ignorer les expirations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Besoin d'aide?
|
||||||
|
|
||||||
|
Consultez la **documentation complète** dans `DOCUMENTATION_API.md` pour:
|
||||||
|
- Architecture détaillée
|
||||||
|
- Configuration avancée
|
||||||
|
- Intégration dans différents frameworks
|
||||||
|
- FAQ et dépannage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Prêt à utiliser l'API!** 🎉
|
||||||
|
|
||||||
159
document/INDEX.md
Normal file
159
document/INDEX.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# 📚 Documentation API mcV3 - Index
|
||||||
|
|
||||||
|
## Bienvenue! 👋
|
||||||
|
|
||||||
|
Vous trouverez ici la documentation complète pour utiliser l'API de mcV3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Commencer par ici
|
||||||
|
|
||||||
|
### 🚀 [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md)
|
||||||
|
**Durée: 5 minutes**
|
||||||
|
|
||||||
|
Guide de démarrage rapide pour commencer immédiatement:
|
||||||
|
- ✅ Créer une clé API
|
||||||
|
- ✅ Accéder à l'API
|
||||||
|
- ✅ Exemples pratiques
|
||||||
|
- ✅ Gestion des erreurs courants
|
||||||
|
|
||||||
|
**Pour:** Utilisateurs pressés qui veulent tester rapidement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 [DOCUMENTATION_API.md](./DOCUMENTATION_API.md)
|
||||||
|
**Durée: 30 minutes pour lecture complète**
|
||||||
|
|
||||||
|
Documentation complète et détaillée couvrant:
|
||||||
|
- 🏗️ Architecture générale du système
|
||||||
|
- 🔐 Tous les détails d'authentification
|
||||||
|
- 📡 Documentation de chaque endpoint API
|
||||||
|
- 🔑 Gestion des clés API
|
||||||
|
- 👥 Rôles et permissions
|
||||||
|
- 📊 Codes HTTP et gestion d'erreurs
|
||||||
|
- 🔧 Intégrations (JS, Python, PHP)
|
||||||
|
- ⚙️ Configuration avancée
|
||||||
|
- ❓ FAQ et dépannage
|
||||||
|
|
||||||
|
**Pour:** Développeurs qui veulent comprendre le système en détail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Accès rapide par sujet
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
- [Guide rapide - Section 2](./GUIDE_USER_RAPIDE_API.md#2️⃣-accéder-à-laapi)
|
||||||
|
- [Détaillé - Authentification](./DOCUMENTATION_API.md#authentification)
|
||||||
|
|
||||||
|
### Endpoints API
|
||||||
|
- [Guide rapide - Section 3](./GUIDE_USER_RAPIDE_API.md#3️⃣-endpoints-disponibles)
|
||||||
|
- [Détaillé - Endpoints API](./DOCUMENTATION_API.md#endpoints-api)
|
||||||
|
|
||||||
|
### Gestion des clés
|
||||||
|
- [Guide rapide - Section 1](./GUIDE_USER_RAPIDE_API.md#1️⃣-créer-une-clé-api)
|
||||||
|
- [Détaillé - Gestion des clés API](./DOCUMENTATION_API.md#gestion-des-clés-api)
|
||||||
|
|
||||||
|
### Rôles et permissions
|
||||||
|
- [Détaillé - Rôles et permissions](./DOCUMENTATION_API.md#rôles-et-permissions)
|
||||||
|
|
||||||
|
### Erreurs et dépannage
|
||||||
|
- [Guide rapide - Section 6](./GUIDE_USER_RAPIDE_API.md#6️⃣-gestion-des-erreurs-courants)
|
||||||
|
- [Détaillé - Codes HTTP](./DOCUMENTATION_API.md#codes-http-et-erreurs)
|
||||||
|
- [Détaillé - FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
|
||||||
|
|
||||||
|
### Intégrations
|
||||||
|
- [Guide rapide - Section 5](./GUIDE_USER_RAPIDE_API.md#5️⃣-utilisation-dans-votre-application)
|
||||||
|
- [Détaillé - Intégrations](./DOCUMENTATION_API.md#intégrations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Parcours d'apprentissage
|
||||||
|
|
||||||
|
### Niveau 1: Utilisateur final (15 min)
|
||||||
|
1. Lire [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md) sections 1-3
|
||||||
|
2. Créer une clé API
|
||||||
|
3. Tester un endpoint
|
||||||
|
|
||||||
|
### Niveau 2: Développeur frontend (1 heure)
|
||||||
|
1. Lire [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md) en entier
|
||||||
|
2. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) sections Authentification + Endpoints
|
||||||
|
3. Implémenter l'intégration dans votre framework
|
||||||
|
4. Tester les erreurs courants
|
||||||
|
|
||||||
|
### Niveau 3: Développeur backend (2 heures)
|
||||||
|
1. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) en entier
|
||||||
|
2. Comprendre l'architecture complète
|
||||||
|
3. Ajouter des endpoints personnalisés
|
||||||
|
4. Implémenter la gestion d'erreurs avancée
|
||||||
|
|
||||||
|
### Niveau 4: Administrateur (30 min)
|
||||||
|
1. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) section Configuration avancée
|
||||||
|
2. Configurer HTTPS en production
|
||||||
|
3. Mettre en place le monitoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Besoin d'aide rapide?
|
||||||
|
|
||||||
|
### "Je veux créer une clé API"
|
||||||
|
→ [GUIDE_USER_RAPIDE_API.md - Section 1](./GUIDE_USER_RAPIDE_API.md#1️⃣-créer-une-clé-api)
|
||||||
|
|
||||||
|
### "Ça ne fonctionne pas"
|
||||||
|
→ [DOCUMENTATION_API.md - FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
|
||||||
|
|
||||||
|
### "Je veux intégrer l'API à mon app"
|
||||||
|
→ [DOCUMENTATION_API.md - Intégrations](./DOCUMENTATION_API.md#intégrations)
|
||||||
|
|
||||||
|
### "Qu'est-ce qu'une clé API?"
|
||||||
|
→ [DOCUMENTATION_API.md - Authentification](./DOCUMENTATION_API.md#mode-1-bearer-token-clé-api)
|
||||||
|
|
||||||
|
### "J'ai une erreur 401"
|
||||||
|
→ [DOCUMENTATION_API.md - Codes HTTP](./DOCUMENTATION_API.md#401-unauthorized)
|
||||||
|
|
||||||
|
### "Quels endpoints sont disponibles?"
|
||||||
|
→ [DOCUMENTATION_API.md - Endpoints API](./DOCUMENTATION_API.md#endpoints-api)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checklist - Avant d'aller en production
|
||||||
|
|
||||||
|
- [ ] Lire la documentation complète
|
||||||
|
- [ ] Créer une clé API de test
|
||||||
|
- [ ] Tester tous les endpoints
|
||||||
|
- [ ] Implémenter la gestion d'erreurs
|
||||||
|
- [ ] Activer HTTPS
|
||||||
|
- [ ] Configurer CORS si nécessaire
|
||||||
|
- [ ] Mettre en place le monitoring
|
||||||
|
- [ ] Documenter vos endpoints personnalisés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer une clé API
|
||||||
|
php bin/console app:api-key:create pseudo_utilisateur "Nom"
|
||||||
|
|
||||||
|
# Lister les routes API
|
||||||
|
php bin/console debug:router | grep api
|
||||||
|
|
||||||
|
# Voir les logs
|
||||||
|
tail -f var/log/dev.log
|
||||||
|
|
||||||
|
# Vider le cache
|
||||||
|
php bin/console cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Pour toutes les questions:
|
||||||
|
1. Vérifier la [FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
|
||||||
|
2. Consulter la [section dépannage](./DOCUMENTATION_API.md#faq-et-dépannage)
|
||||||
|
3. Vérifier les [codes d'erreur](./DOCUMENTATION_API.md#codes-http-et-erreurs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour: 2026-04-03**
|
||||||
|
|
||||||
|
|
@ -35,4 +35,17 @@ return [
|
||||||
'version' => '5.3.8',
|
'version' => '5.3.8',
|
||||||
'type' => 'css',
|
'type' => 'css',
|
||||||
],
|
],
|
||||||
|
'tom-select' => [
|
||||||
|
'version' => '2.5.2',
|
||||||
|
],
|
||||||
|
'@orchidjs/sifter' => [
|
||||||
|
'version' => '1.1.0',
|
||||||
|
],
|
||||||
|
'@orchidjs/unicode-variants' => [
|
||||||
|
'version' => '1.1.2',
|
||||||
|
],
|
||||||
|
'tom-select/dist/css/tom-select.default.min.css' => [
|
||||||
|
'version' => '2.5.2',
|
||||||
|
'type' => 'css',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
31
migrations/Version20260403163636.php
Normal file
31
migrations/Version20260403163636.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260403163636 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD bio TEXT NOT NULL default \'\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP bio');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
migrations/Version20260403164519.php
Normal file
37
migrations/Version20260403164519.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260403164519 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE api_key (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_active BOOLEAN NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_C912ED9D5F37A13B ON api_key (token)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_C912ED9DA76ED395 ON api_key (user_id)');
|
||||||
|
$this->addSql('ALTER TABLE api_key ADD CONSTRAINT FK_C912ED9DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE "user" ALTER bio DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE api_key DROP CONSTRAINT FK_C912ED9DA76ED395');
|
||||||
|
$this->addSql('DROP TABLE api_key');
|
||||||
|
$this->addSql('ALTER TABLE "user" ALTER bio SET DEFAULT \'\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
migrations/Version20260405171429.php
Normal file
37
migrations/Version20260405171429.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260405171429 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE mod_modpack DROP CONSTRAINT fk_9449c4f4949d6aeb');
|
||||||
|
$this->addSql('ALTER TABLE mod_modpack DROP CONSTRAINT fk_9449c4f4338e21cd');
|
||||||
|
$this->addSql('DROP TABLE mod_modpack');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE mod_modpack (mod_id INT NOT NULL, modpack_id INT NOT NULL, PRIMARY KEY (mod_id, modpack_id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_9449c4f4338e21cd ON mod_modpack (mod_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_9449c4f4949d6aeb ON mod_modpack (modpack_id)');
|
||||||
|
$this->addSql('ALTER TABLE mod_modpack ADD CONSTRAINT fk_9449c4f4949d6aeb FOREIGN KEY (modpack_id) REFERENCES modpack (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE mod_modpack ADD CONSTRAINT fk_9449c4f4338e21cd FOREIGN KEY (mod_id) REFERENCES mod (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260406173517.php
Normal file
31
migrations/Version20260406173517.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260406173517 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE "user" RENAME COLUMN theme_sombre TO default_pwd');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE "user" RENAME COLUMN default_pwd TO theme_sombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260407125351.php
Normal file
31
migrations/Version20260407125351.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260407125351 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE modpack ADD downloadable BOOLEAN NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE modpack DROP downloadable');
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Command/ApiKeyCreateCommand.php
Normal file
79
src/Command/ApiKeyCreateCommand.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\ApiKey;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:api-key:create',
|
||||||
|
description: 'Créer une clé API pour un utilisateur',
|
||||||
|
)]
|
||||||
|
class ApiKeyCreateCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private EntityManagerInterface $entityManager
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('pseudo', InputArgument::REQUIRED, 'Le pseudo de l\'utilisateur')
|
||||||
|
->addArgument('name', InputArgument::REQUIRED, 'Le nom de la clé API')
|
||||||
|
->addOption('expires', null, InputOption::VALUE_OPTIONAL, 'Date d\'expiration (format Y-m-d H:i:s)', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$pseudo = $input->getArgument('pseudo');
|
||||||
|
$name = $input->getArgument('name');
|
||||||
|
$expires = $input->getOption('expires');
|
||||||
|
|
||||||
|
$user = $this->userRepository->findOneBy(['pseudo' => $pseudo]);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$io->error("Utilisateur '$pseudo' non trouvé");
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = new ApiKey();
|
||||||
|
$apiKey->setName($name);
|
||||||
|
$apiKey->setUser($user);
|
||||||
|
|
||||||
|
if ($expires) {
|
||||||
|
try {
|
||||||
|
$apiKey->setExpiresAt(new \DateTime($expires));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$io->error("Format de date invalide: {$e->getMessage()}");
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($apiKey);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success("Clé API créée avec succès!");
|
||||||
|
$io->text([
|
||||||
|
"Utilisateur: {$user->getPseudo()}",
|
||||||
|
"Nom: {$apiKey->getName()}",
|
||||||
|
"Token: {$apiKey->getToken()}",
|
||||||
|
"Créée à: {$apiKey->getCreatedAt()->format('Y-m-d H:i:s')}",
|
||||||
|
$expires ? "Expire à: {$apiKey->getExpiresAt()->format('Y-m-d H:i:s')}" : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
61
src/Command/ApiTestCommand.php
Normal file
61
src/Command/ApiTestCommand.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:api:test',
|
||||||
|
description: 'Affiche des informations sur comment tester l\'API',
|
||||||
|
)]
|
||||||
|
class ApiTestCommand extends Command
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$io->title('🚀 Test de l\'API avec authentification par clé');
|
||||||
|
|
||||||
|
$io->section('1️⃣ Créer une clé API');
|
||||||
|
$io->text('Commande:');
|
||||||
|
$io->block('php bin/console app:api-key:create {pseudo} {nom_clé}', null, 'fg=black;bg=cyan', ' ');
|
||||||
|
$io->text('Exemple:');
|
||||||
|
$io->block('php bin/console app:api-key:create john "Mon API"', null, 'fg=black;bg=cyan', ' ');
|
||||||
|
|
||||||
|
$io->section('2️⃣ Vérifier l\'état de l\'API (sans authentification)');
|
||||||
|
$io->text('Commande cURL:');
|
||||||
|
$io->block('curl http://localhost:8000/api/health', null, 'fg=black;bg=cyan', ' ');
|
||||||
|
|
||||||
|
$io->section('3️⃣ Récupérer le profil (avec authentification)');
|
||||||
|
$io->text('Remplacer TOKEN par le token généré à l\'étape 1');
|
||||||
|
$io->block('curl -X GET http://localhost:8000/api/profile -H "Authorization: Bearer TOKEN"', null, 'fg=black;bg=cyan', ' ');
|
||||||
|
|
||||||
|
$io->section('4️⃣ Autres endpoints');
|
||||||
|
$endpoints = [
|
||||||
|
'GET /api/api-keys' => 'Lister toutes vos clés API',
|
||||||
|
'POST /api/api-keys' => 'Créer une nouvelle clé (JSON body avec "name")',
|
||||||
|
'DELETE /api/api-keys/{id}' => 'Supprimer une clé',
|
||||||
|
'PATCH /api/api-keys/{id}/deactivate' => 'Désactiver une clé',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($endpoints as $route => $description) {
|
||||||
|
$io->text("<info>$route</info> - $description");
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('📚 Documentation');
|
||||||
|
$io->text('Pour plus de détails, consultez:');
|
||||||
|
$io->listing([
|
||||||
|
'API_DOCUMENTATION.md - Documentation complète',
|
||||||
|
'GUIDE_API_RAPIDE.md - Guide de démarrage',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success('Prêt à utiliser votre API! 🎉');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,17 +2,388 @@
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Constitue;
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Entity\Modpack;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Mod\ModType;
|
||||||
|
use App\Form\Modpack\ModpackType;
|
||||||
|
use App\Form\User\UserFormType;
|
||||||
|
use App\Service\UploaderService;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
|
||||||
|
#[Route('/administration')]
|
||||||
final class AdministrationController extends AbstractController
|
final class AdministrationController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/administration', name: 'app_administration')]
|
#[Route('', name: 'app_admin')]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->render('administration/index.html.twig', [
|
return $this->render('administration/index.html.twig', [
|
||||||
'controller_name' => 'AdministrationController',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Users */
|
||||||
|
#[route('/users', name: 'app_admin_users')]
|
||||||
|
public function admin_users(EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$users = $em->getRepository(User::class)->findAll(array(), array('id' => 'ASC'));
|
||||||
|
|
||||||
|
return $this->render('administration/users/index.html.twig', [
|
||||||
|
'users' => $users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/users/add', name: 'app_admin_users_add')]
|
||||||
|
public function admin_users_add(Request $request, EntityManagerInterface $em, #[currentUser] User $currentUser, UserPasswordHasherInterface $passwordHasher): Response
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$form = $this->createForm(UserFormType::class, $user);
|
||||||
|
$form ->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$plainPassword = $form->get('password')->getData();
|
||||||
|
|
||||||
|
if (empty($plainPassword)) {
|
||||||
|
$plainPassword = $user->getPseudo();
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashedPassword = $passwordHasher->hashPassword($user, $plainPassword);
|
||||||
|
$user->setPassword($hashedPassword);
|
||||||
|
|
||||||
|
$user->setCreator($currentUser);
|
||||||
|
$user->addRole('ROLE_USER');
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && !$form->isValid()) {
|
||||||
|
$errors = $form->getErrors(true);
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
$this->addFlash('warning',$error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_users_add');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return $this->render('administration/users/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[route('/users/{iduser}', name: 'app_admin_users_edit')]
|
||||||
|
public function admin_users_edit(Request $request, EntityManagerInterface $em,#[currentUser] User $currentUser,UserPasswordHasherInterface $passwordHasher, int $iduser): Response
|
||||||
|
{
|
||||||
|
$user = $em->getRepository(User::class)->find($iduser);
|
||||||
|
if (!$user) {
|
||||||
|
$this->addFlash('warning', 'Utilisateur non trouvé.');
|
||||||
|
return $this->redirectToRoute('app_admin_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$currentUser->isAncestorOf($user)) {
|
||||||
|
$this->addFlash('danger', 'Vous n\'avez pas la permission de modifier cet utilisateur.');
|
||||||
|
return $this->redirectToRoute('app_admin_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
$old_pwd = $user->getPseudo();
|
||||||
|
|
||||||
|
$form = $this->createForm(UserFormType::class, $user);
|
||||||
|
$form ->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$plainPassword = $form->get('password')->getData();
|
||||||
|
|
||||||
|
if (empty($plainPassword)) {
|
||||||
|
$plainPassword = $old_pwd;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$hashedPassword = $passwordHasher->hashPassword($user, $plainPassword);
|
||||||
|
$user->setPassword($hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Utilisateur modifié avec succès.');
|
||||||
|
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && !$form->isValid()) {
|
||||||
|
$errors = $form->getErrors(true);
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
$this->addFlash('warning',$error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_users_edit', ['iduser' => $user->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('administration/users/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mods & Modpack*/
|
||||||
|
#[Route('/mods', name: 'app_admin_mods')]
|
||||||
|
public function admin_mods(EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$mods = $em->getRepository(Mod::class)->findBy(array(), array('nom' => 'ASC','version' => 'ASC'));
|
||||||
|
|
||||||
|
return $this->render('administration/mods/index.html.twig', [
|
||||||
|
'mods' => $mods,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/mods/add', name: 'app_admin_mods_add')]
|
||||||
|
public function admin_mods_add(Request $request, EntityManagerInterface $em, UploaderService $uploaderService): Response
|
||||||
|
{
|
||||||
|
$mod = new Mod();
|
||||||
|
$form = $this->createForm(ModType::class, $mod);
|
||||||
|
$form ->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var UploadedFile $photo */
|
||||||
|
$filemod = $form->get('uri')->getData();
|
||||||
|
if (!$filemod) {
|
||||||
|
$this->addFlash('danger', 'Fichier du mod manquant.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods_add');
|
||||||
|
}
|
||||||
|
$filemodname = null;
|
||||||
|
try {
|
||||||
|
$filemodname = $uploaderService->upload($filemod, UploaderService::DIR_MOD,true);
|
||||||
|
}
|
||||||
|
catch (\Exception $exception){
|
||||||
|
$this->addFlash('danger','Le mod est déjà enregistré.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods_add');
|
||||||
|
}
|
||||||
|
$mod->setUri($filemodname);
|
||||||
|
|
||||||
|
$em->persist($mod);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Mod ajouté avec succès.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
if ($form->isSubmitted() && !$form->isValid()) {
|
||||||
|
$errors = $form->getErrors(true);
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
if ($errorMessage === 'Mod already exists'){
|
||||||
|
$this->addFlash('danger', 'Le mod existe déjà. Veuillez vérifier le nom et la version du mod que vous essayez d\'ajouter.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods_add');
|
||||||
|
}
|
||||||
|
$this->addFlash('warning',$errorMessage);
|
||||||
|
}
|
||||||
|
return $this->redirectToRoute('app_admin_mods_add');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('administration/mods/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[route('/mods/{idmod}', name: 'app_admin_mods_edit')]
|
||||||
|
public function admin_mods_edit(Request $request, EntityManagerInterface $em, int $idmod): Response
|
||||||
|
{
|
||||||
|
$mod = $em->getRepository(Mod::class)->find($idmod);
|
||||||
|
if (!$mod) {
|
||||||
|
$this->addFlash('warning', 'Mod non trouvé.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
$form = $this->createForm(ModType::class,$mod);
|
||||||
|
$form ->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
|
||||||
|
$em->persist($mod);
|
||||||
|
try {
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
catch (\Exception $exception){
|
||||||
|
$this->addFlash('danger', 'Le mod existe déjà. Veuillez vérifier la version du mod que vous essayez de modifier.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods_edit', ['idmod' => $idmod]);
|
||||||
|
}
|
||||||
|
$this->addFlash('success', 'Mod modifié avec succès.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('administration/mods/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[route('/mods/{idmod}/delete', name: 'app_admin_mods_delete')]
|
||||||
|
public function admin_mods_delete(Request $request, EntityManagerInterface $em, UploaderService $uploaderService,int $idmod): Response
|
||||||
|
{
|
||||||
|
$mod = $em->getRepository(Mod::class)->find($idmod);
|
||||||
|
if (!$mod) {
|
||||||
|
$this->addFlash('warning', 'Mod non trouvé.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
|
||||||
|
if($mod->isUsed()){
|
||||||
|
$this->addFlash('danger', 'Impossible de supprimer le mod car il est utilisé dans un modpack. Veuillez d\'abord le retirer des modpacks qui l\'utilisent.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
|
||||||
|
if($mod->isADependance()){
|
||||||
|
$this->addFlash('danger', 'Impossible de supprimer le mod car il est une dépendance d\'un autre mod. Veuillez d\'abord retirer cette dépendance des mods qui l\'utilisent.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $uploaderService->delete($mod->getUri(), UploaderService::DIR_MOD);
|
||||||
|
if (empty($res)) {
|
||||||
|
$this->addFlash('warning', 'Fichier du mod introuvable ou déjà supprimé. Le mod a été supprimé de la base de données.');
|
||||||
|
}
|
||||||
|
if (!$res) {
|
||||||
|
$this->addFlash('danger', 'Impossible de supprimer le mod.');
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
$em->remove($mod);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Mod supprimé avec succès.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_mods');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/modpacks', name: 'app_admin_modpacks')]
|
||||||
|
public function admin_modpacks(EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$modpacks = $em->getRepository(Modpack::class)->findBy(array(), array('version' => 'DESC'));
|
||||||
|
|
||||||
|
return $this->render('administration/modpacks/index.html.twig', [
|
||||||
|
'modpacks' => $modpacks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/modpacks/add', name: 'app_admin_modpacks_add')]
|
||||||
|
public function admin_modpacks_add(Request $request, EntityManagerInterface $em): Response{
|
||||||
|
$modpack = new Modpack();
|
||||||
|
$form = $this->createForm(ModpackType::class, $modpack);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$optionnelsMods = $form->get('mods_optionnels')->getData() ?? new ArrayCollection();
|
||||||
|
|
||||||
|
foreach ($form->get('mods')->getData() as $mod) {
|
||||||
|
$c = new Constitue();
|
||||||
|
$c->setModpack($modpack);
|
||||||
|
$c->setMod($mod);
|
||||||
|
$c->setOptionnel($optionnelsMods->contains($mod));
|
||||||
|
$em->persist($c);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($modpack);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Modpack ajouté avec succès.');
|
||||||
|
return $this->render('administration/modpacks/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/modpacks/{id}', name: 'app_admin_modpacks_edit')]
|
||||||
|
public function admin_modpacks_edit(Request $request, EntityManagerInterface $em, int $id): Response
|
||||||
|
{
|
||||||
|
$modpack = $em->getRepository(Modpack::class)->find($id);
|
||||||
|
if (!$modpack) {
|
||||||
|
$this->addFlash('warning', 'Modpack non trouvé.');
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(ModpackType::class, $modpack);
|
||||||
|
|
||||||
|
// Pré-remplissage des champs mapped:false
|
||||||
|
$form->get('mods')->setData($modpack->getMods());
|
||||||
|
$form->get('mods_optionnels')->setData(
|
||||||
|
$modpack->getConstitues()
|
||||||
|
->filter(fn(Constitue $c) => $c->isOptionnel())
|
||||||
|
->map(fn(Constitue $c) => $c->getMod())
|
||||||
|
);
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$optionnelsMods = $form->get('mods_optionnels')->getData() ?? new ArrayCollection();
|
||||||
|
$newMods = $form->get('mods')->getData()->toArray();
|
||||||
|
|
||||||
|
foreach ($modpack->getConstitues() as $constitue) {
|
||||||
|
$mod = $constitue->getMod();
|
||||||
|
|
||||||
|
if (!in_array($mod, $newMods, true)) {
|
||||||
|
// Mod retiré : suppression
|
||||||
|
$em->remove($constitue);
|
||||||
|
} else {
|
||||||
|
// Mod conservé : mise à jour de l'optionnel si besoin
|
||||||
|
$isOptionnel = $optionnelsMods->contains($mod);
|
||||||
|
if ($constitue->isOptionnel() !== $isOptionnel) {
|
||||||
|
$constitue->setOptionnel($isOptionnel);
|
||||||
|
}
|
||||||
|
// On le retire de $newMods pour ne pas le recréer
|
||||||
|
$newMods = array_filter($newMods, fn($m) => $m !== $mod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mods restants dans $newMods : nouveaux à créer
|
||||||
|
foreach ($newMods as $mod) {
|
||||||
|
$c = new Constitue();
|
||||||
|
$c->setModpack($modpack);
|
||||||
|
$c->setMod($mod);
|
||||||
|
$c->setOptionnel($optionnelsMods->contains($mod));
|
||||||
|
$em->persist($c);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($modpack);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Modpack modifié avec succès.');
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('administration/modpacks/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[route('/modpacks/{id}/delete', name: 'app_admin_modpacks_delete')]
|
||||||
|
public function admin_modpacks_delete(Request $request, EntityManagerInterface $em, UploaderService $uploaderService,int $id): Response
|
||||||
|
{
|
||||||
|
$modpack = $em->getRepository(Modpack::class)->find($id);
|
||||||
|
if (!$modpack) {
|
||||||
|
$this->addFlash('warning', 'Modpack non trouvé.');
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($modpack->isDownloadable()) {
|
||||||
|
$this->addFlash('warning', 'Veuillez retirer ce modpack des versions téléchargeables pour le supprimer.');
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($modpack->getConstitues() as $constitue) {
|
||||||
|
$em->remove($constitue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->remove($modpack);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Modpack supprimé avec succès.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
src/Controller/Api/V1/ApiKeyController.php
Normal file
156
src/Controller/Api/V1/ApiKeyController.php
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use App\Entity\ApiKey;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ApiKeyRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
final class ApiKeyController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/api-keys', name: 'api_keys_list', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function listApiKeys(ApiKeyRepository $apiKeyRepository): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Les admins voient toutes les clés, les autres ne voient que les leurs
|
||||||
|
if ($this->isGranted('ROLE_ADMIN')) {
|
||||||
|
$keys = $apiKeyRepository->findAll();
|
||||||
|
} else {
|
||||||
|
$keys = $apiKeyRepository->findByUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = array_map(function (ApiKey $key) {
|
||||||
|
return [
|
||||||
|
'id' => $key->getId(),
|
||||||
|
'name' => $key->getName(),
|
||||||
|
'owner' => $key->getUser()->getPseudo(),
|
||||||
|
'token' => substr($key->getToken(), 0, 10) . '...', // Masquer le token complet
|
||||||
|
'createdAt' => $key->getCreatedAt()?->format('c'),
|
||||||
|
'lastUsedAt' => $key->getLastUsedAt()?->format('c'),
|
||||||
|
'expiresAt' => $key->getExpiresAt()?->format('c'),
|
||||||
|
'isActive' => $key->isActive(),
|
||||||
|
];
|
||||||
|
}, $keys);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'apiKeys' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api-keys', name: 'api_keys_create', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function createApiKey(
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $entityManager
|
||||||
|
): JsonResponse {
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!isset($data['name'])) {
|
||||||
|
return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les admins peuvent créer des clés pour d'autres utilisateurs
|
||||||
|
$currentUser = $this->getUser();
|
||||||
|
if ($this->isGranted('ROLE_ADMIN') && isset($data['userId'])) {
|
||||||
|
// Un admin veut créer une clé pour un autre utilisateur
|
||||||
|
// Cette logique dépend de votre implémentation
|
||||||
|
$user = $entityManager->getRepository(User::class)->find($data['userId']);
|
||||||
|
if (!$user) {
|
||||||
|
return $this->json(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$user = $currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = new ApiKey();
|
||||||
|
$apiKey->setName($data['name']);
|
||||||
|
$apiKey->setUser($user);
|
||||||
|
|
||||||
|
if (isset($data['expiresAt'])) {
|
||||||
|
$apiKey->setExpiresAt(new \DateTime($data['expiresAt']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityManager->persist($apiKey);
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'id' => $apiKey->getId(),
|
||||||
|
'name' => $apiKey->getName(),
|
||||||
|
'token' => $apiKey->getToken(),
|
||||||
|
'createdAt' => $apiKey->getCreatedAt()?->format('c'),
|
||||||
|
'message' => 'Copier le token en lieu sûr',
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api-keys/{id}', name: 'api_keys_get', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function getApiKey(
|
||||||
|
ApiKey $apiKey,
|
||||||
|
EntityManagerInterface $entityManager
|
||||||
|
): JsonResponse {
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Seuls les propriétaires peuvent voir les détails d'une clé
|
||||||
|
if ($apiKey->getUser() !== $user) {
|
||||||
|
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'id' => $apiKey->getId(),
|
||||||
|
'name' => $apiKey->getName(),
|
||||||
|
'token' => $apiKey->getToken(),
|
||||||
|
'createdAt' => $apiKey->getCreatedAt()?->format('c'),
|
||||||
|
'lastUsedAt' => $apiKey->getLastUsedAt()?->format('c'),
|
||||||
|
'expiresAt' => $apiKey->getExpiresAt()?->format('c'),
|
||||||
|
'isActive' => $apiKey->isActive(),
|
||||||
|
'message' => 'Copier le token en lieu sûr',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api-keys/{id}', name: 'api_keys_delete', methods: ['DELETE'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function deleteApiKey(
|
||||||
|
ApiKey $apiKey,
|
||||||
|
EntityManagerInterface $entityManager
|
||||||
|
): JsonResponse {
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Les admins peuvent supprimer n'importe quelle clé, les autres seulement les leurs
|
||||||
|
if (!$this->isGranted('ROLE_ADMIN') && $apiKey->getUser() !== $user) {
|
||||||
|
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityManager->remove($apiKey);
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json(['message' => 'API key deleted']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api-keys/{id}/deactivate', name: 'api_keys_deactivate', methods: ['PATCH'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function deactivateApiKey(
|
||||||
|
ApiKey $apiKey,
|
||||||
|
EntityManagerInterface $entityManager
|
||||||
|
): JsonResponse {
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Les admins peuvent désactiver n'importe quelle clé, les autres seulement les leurs
|
||||||
|
if (!$this->isGranted('ROLE_ADMIN') && $apiKey->getUser() !== $user) {
|
||||||
|
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey->setIsActive(false);
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json(['message' => 'API key deactivated']);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Controller/Api/V1/StateController.php
Normal file
55
src/Controller/Api/V1/StateController.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
final class StateController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/health', name: 'app_api_v1_health', methods: ['GET'])]
|
||||||
|
public function health(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->json([
|
||||||
|
'status' => 'ok',
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/profile', name: 'app_api_v1_profile', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function profile(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'pseudo' => $user->getPseudo(),
|
||||||
|
'roles' => $user->getRoles(),
|
||||||
|
'authenticatedVia' => $this->getAuthenticationMode(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déterminer le mode d'authentification utilisé
|
||||||
|
*/
|
||||||
|
private function getAuthenticationMode(): string
|
||||||
|
{
|
||||||
|
$token = $this->container->get('security.token_storage')->getToken();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenClass = basename(str_replace('\\', '/', $token::class));
|
||||||
|
|
||||||
|
if (strpos($tokenClass, 'AccessToken') !== false) {
|
||||||
|
return 'api_key';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'session';
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Controller/ModpackController.php
Normal file
87
src/Controller/ModpackController.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Entity\Modpack;
|
||||||
|
use App\Form\Modpack\ModpackDownloadType;
|
||||||
|
use App\Service\ModpackZipService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/modpacks')]
|
||||||
|
final class ModpackController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'app_modpack')]
|
||||||
|
public function index(Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$modpacks = $em->getRepository(Modpack::class)->findBy(
|
||||||
|
['downloadable' => true],
|
||||||
|
['version' => 'DESC']
|
||||||
|
);
|
||||||
|
|
||||||
|
$id = $request->query->get('v');
|
||||||
|
$modpack = $id
|
||||||
|
? $em->getRepository(Modpack::class)->find($id)
|
||||||
|
: ($modpacks[0] ?? null);
|
||||||
|
|
||||||
|
// Sécurité : le modpack demandé doit être downloadable
|
||||||
|
if (!$modpack || !$modpack->isDownloadable()) {
|
||||||
|
$modpack = $modpacks[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(ModpackDownloadType::class, $modpack);
|
||||||
|
|
||||||
|
return $this->render('modpack/index.html.twig', [
|
||||||
|
'modpack' => $modpack,
|
||||||
|
'modpacks' => $modpacks,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/download', name: 'app_modpack_download')]
|
||||||
|
public function download(
|
||||||
|
Modpack $modpack,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
ModpackZipService $modpackZipService
|
||||||
|
): Response {
|
||||||
|
// Mods obligatoires
|
||||||
|
$mods = $modpack->getConstitues()
|
||||||
|
->filter(fn($c) => !$c->isOptionnel())
|
||||||
|
->map(fn($c) => $c->getMod())
|
||||||
|
->getValues();
|
||||||
|
|
||||||
|
// Mods optionnels depuis ?opt=1+2+3
|
||||||
|
$optParam = $request->query->get('opt', '');
|
||||||
|
$optIds = array_filter(explode(' ', $optParam)); // "+" décodé en espace par PHP
|
||||||
|
|
||||||
|
if (!empty($optIds)) {
|
||||||
|
// Sécurité : on vérifie que les IDs appartiennent bien au modpack
|
||||||
|
$allowedOptIds = $modpack->getConstitues()
|
||||||
|
->filter(fn($c) => $c->isOptionnel())
|
||||||
|
->map(fn($c) => (string) $c->getMod()->getId())
|
||||||
|
->getValues();
|
||||||
|
|
||||||
|
$safeIds = array_intersect($optIds, $allowedOptIds);
|
||||||
|
$modsOptionnels = $em->getRepository(Mod::class)->findBy(['id' => $safeIds]);
|
||||||
|
$mods = array_merge($mods, $modsOptionnels);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipPath = $modpackZipService->createZip($modpack, $mods);
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($zipPath);
|
||||||
|
$response->setContentDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
|
'modpack-' . $modpack->getVersion() . '.zip'
|
||||||
|
);
|
||||||
|
$response->deleteFileAfterSend(true);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ class SecurityController extends AbstractController
|
||||||
$user->setRoles(["ROLE_ADMIN","ROLE_USER"]);
|
$user->setRoles(["ROLE_ADMIN","ROLE_USER"]);
|
||||||
$user->setPseudo("admin");
|
$user->setPseudo("admin");
|
||||||
$user->setPassword('$2y$13$CVJ/Hm29HJ3ehPrqrtMl8Oya55d/ZXwlhfL6D2TsWI238bHAqtCrS');
|
$user->setPassword('$2y$13$CVJ/Hm29HJ3ehPrqrtMl8Oya55d/ZXwlhfL6D2TsWI238bHAqtCrS');
|
||||||
$user->setThemeSombre(false);
|
$user->setDefaultpwd(true);
|
||||||
$em->persist($user);
|
$em->persist($user);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ class SecurityController extends AbstractController
|
||||||
// get the login error if there is one
|
// get the login error if there is one
|
||||||
$error = $authenticationUtils->getLastAuthenticationError();
|
$error = $authenticationUtils->getLastAuthenticationError();
|
||||||
|
|
||||||
|
|
||||||
// last username entered by the user
|
// last username entered by the user
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
$lastUsername = $authenticationUtils->getLastUsername();
|
||||||
|
|
||||||
|
|
|
||||||
98
src/Controller/SettingsController.php
Normal file
98
src/Controller/SettingsController.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\User\Settings\ProfileType;
|
||||||
|
use App\Form\User\Settings\SecurityType;
|
||||||
|
use App\Service\UploaderService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
|
||||||
|
#[Route('/settings')]
|
||||||
|
final class SettingsController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'app_settings')]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->redirectToRoute('app_settings_profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/profile', name: 'app_settings_profile')]
|
||||||
|
public function profile(Request $request, EntityManagerInterface $em,#[currentUser] User $user, UploaderService $uploaderService): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(ProfileType::class, $user);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var UploadedFile $photo */
|
||||||
|
$photo = $form->get('profile_picture_file')->getData();
|
||||||
|
$nomPhoto = null;
|
||||||
|
if ($photo) {
|
||||||
|
$nomPhoto = $uploaderService->upload($photo, UploaderService::DIR_PP);
|
||||||
|
}
|
||||||
|
else if ($form->get('uri_pp')->getData()) {
|
||||||
|
$nomPhoto = $form->get('uri_pp')->getData();
|
||||||
|
}
|
||||||
|
if ($nomPhoto) {
|
||||||
|
if (!$user->hasURLpp() && $user->getUriPp()){
|
||||||
|
// Supprimer l'ancienne photo si elle existe et n'est pas une URL
|
||||||
|
$uploaderService->delete($user->getUriPp(), UploaderService::DIR_PP);
|
||||||
|
}
|
||||||
|
$user->setUriPp($nomPhoto);
|
||||||
|
}
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Profil mis à jour avec succès.');
|
||||||
|
return $this->redirectToRoute('app_settings_profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('settings/profile.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/security', name: 'app_settings_security')]
|
||||||
|
public function security(Request $request, EntityManagerInterface $em,#[currentUser] User $user,UserPasswordHasherInterface $passwordHasher): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(SecurityType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
if (!password_verify($form->get('current_password')->getData(), $user->getPassword())) {
|
||||||
|
$this->addFlash('danger', 'Mot de passe actuel incorrect.');
|
||||||
|
return $this->redirectToRoute('app_settings_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->get('new_password')->getData() !== $form->get('new_password_confirmation')->getData()) {
|
||||||
|
$this->addFlash('danger', 'Les mots de passe ne correspondent pas.');
|
||||||
|
return $this->redirectToRoute('app_settings_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->get('new_password')->getData() === $form->get('current_password')->getData()){
|
||||||
|
$this->addFlash('danger', 'Le nouveau mot de passe doit être différent de l\'ancien.');
|
||||||
|
return $this->redirectToRoute('app_settings_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = $passwordHasher->hashPassword($user, $form->get('new_password')->getData());
|
||||||
|
$user->setPassword($hash);
|
||||||
|
$user->setDefaultPwd(false);
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash("success","Mot de passe mis à jour avec succès.");
|
||||||
|
return $this->redirectToRoute('app_settings_security');
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('settings/security.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,7 @@ final class VitrineController extends AbstractController
|
||||||
#[Route('/', name: 'app_accueil')]
|
#[Route('/', name: 'app_accueil')]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->render('vitrine/index.html.twig', [
|
return $this->render('vitrine/index.html.twig', []);
|
||||||
'controller_name' => 'VitrineController',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
src/Entity/ApiKey.php
Normal file
151
src/Entity/ApiKey.php
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ApiKeyRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ApiKeyRepository::class)]
|
||||||
|
class ApiKey
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, unique: true)]
|
||||||
|
private ?string $token = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
|
private ?\DateTimeInterface $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeInterface $lastUsedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeInterface $expiresAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $isActive = true;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTime();
|
||||||
|
// Générer un token unique (64 caractères aléatoires en hex)
|
||||||
|
$this->token = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getToken(): ?string
|
||||||
|
{
|
||||||
|
return $this->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setToken(string $token): static
|
||||||
|
{
|
||||||
|
$this->token = $token;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeInterface $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastUsedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->lastUsedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastUsedAt(?\DateTimeInterface $lastUsedAt): static
|
||||||
|
{
|
||||||
|
$this->lastUsedAt = $lastUsedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiresAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExpiresAt(?\DateTimeInterface $expiresAt): static
|
||||||
|
{
|
||||||
|
$this->expiresAt = $expiresAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsActive(bool $isActive): static
|
||||||
|
{
|
||||||
|
$this->isActive = $isActive;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la clé API est valide (active et non expirée)
|
||||||
|
*/
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
if (!$this->isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->expiresAt && $this->expiresAt < new \DateTime()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: ModRepository::class)]
|
#[ORM\Entity(repositoryClass: ModRepository::class)]
|
||||||
#[ORM\UniqueConstraint(name: 'mod_unique', columns: ['nom', 'version'])]
|
#[ORM\UniqueConstraint(name: 'mod_unique', columns: ['nom', 'version'])]
|
||||||
#[UniqueEntity(fields: ['nom', 'version'], message: 'relation already exists')]
|
#[UniqueEntity(fields: ['nom', 'version'], message: 'Mod already exists')]
|
||||||
class Mod
|
class Mod
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
|
|
@ -35,27 +35,22 @@ class Mod
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, self>
|
* @var Collection<int, self>
|
||||||
|
* .
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: self::class, mappedBy: 'dependances')]
|
#[ORM\ManyToMany(targetEntity: self::class, mappedBy: 'dependances')]
|
||||||
private Collection $soumis;
|
private Collection $soumis;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Collection<int, Modpack>
|
|
||||||
*/
|
|
||||||
#[ORM\ManyToMany(targetEntity: Modpack::class, inversedBy: 'mods')]
|
|
||||||
private Collection $constitue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, Constitue>
|
* @var Collection<int, Constitue>
|
||||||
*/
|
*/
|
||||||
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'mod')]
|
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'mod')]
|
||||||
private Collection $constitues;
|
private Collection $constitues;
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->soumis = new ArrayCollection();
|
$this->soumis = new ArrayCollection();
|
||||||
$this->dependances = new ArrayCollection();
|
$this->dependances = new ArrayCollection();
|
||||||
$this->constitue = new ArrayCollection();
|
|
||||||
$this->constitues = new ArrayCollection();
|
$this->constitues = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,4 +175,13 @@ class Mod
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isUsed(): bool{
|
||||||
|
return !$this->constitues->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isADependance(): bool{
|
||||||
|
return !$this->soumis->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class Modpack
|
||||||
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'modpack')]
|
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'modpack')]
|
||||||
private Collection $constitues;
|
private Collection $constitues;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $downloadable = false;
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->constitues = new ArrayCollection();
|
$this->constitues = new ArrayCollection();
|
||||||
|
|
@ -75,4 +79,42 @@ class Modpack
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getConstituteByMod(Mod $mod): ?Constitue
|
||||||
|
{
|
||||||
|
foreach ($this->constitues as $constitue) {
|
||||||
|
if ($constitue->getMod() === $mod) {
|
||||||
|
return $constitue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMods(): Collection
|
||||||
|
{
|
||||||
|
$mods = new ArrayCollection();
|
||||||
|
foreach ($this->constitues as $constitue) {
|
||||||
|
$mods->add($constitue->getMod());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDownloadable(): bool
|
||||||
|
{
|
||||||
|
return $this->downloadable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDownloadable(bool $downloadable): static
|
||||||
|
{
|
||||||
|
$this->downloadable = $downloadable;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function numberOfMods(): int
|
||||||
|
{
|
||||||
|
return count($this->constitues);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Entity;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
@ -38,7 +39,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
private ?string $uri_pp = null;
|
private ?string $uri_pp = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?bool $theme_sombre = null;
|
private ?bool $default_pwd = true;
|
||||||
|
|
||||||
#[ORM\OneToOne(inversedBy: 'owner', cascade: ['persist', 'remove'])]
|
#[ORM\OneToOne(inversedBy: 'owner', cascade: ['persist', 'remove'])]
|
||||||
private ?Joueur $joueur = null;
|
private ?Joueur $joueur = null;
|
||||||
|
|
@ -58,6 +59,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
#[ORM\OneToMany(targetEntity: Publication::class, mappedBy: 'author')]
|
#[ORM\OneToMany(targetEntity: Publication::class, mappedBy: 'author')]
|
||||||
private Collection $publications;
|
private Collection $publications;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: false)]
|
||||||
|
private ?string $bio = "";
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->users = new ArrayCollection();
|
$this->users = new ArrayCollection();
|
||||||
|
|
@ -103,6 +107,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
return array_unique($roles);
|
return array_unique($roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasRole(string $role): bool
|
||||||
|
{
|
||||||
|
return in_array($role, $this->getRoles(), true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $roles
|
* @param list<string> $roles
|
||||||
*/
|
*/
|
||||||
|
|
@ -113,6 +122,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function addRole(string $role): static
|
||||||
|
{
|
||||||
|
$this->roles[] = $role;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see PasswordAuthenticatedUserInterface
|
* @see PasswordAuthenticatedUserInterface
|
||||||
*/
|
*/
|
||||||
|
|
@ -157,14 +172,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isThemeSombre(): ?bool
|
public function hasDefaultpwd(): bool
|
||||||
{
|
{
|
||||||
return $this->theme_sombre;
|
return $this->default_pwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setThemeSombre(bool $theme_sombre): static
|
public function setDefaultpwd(bool $default_pwd): static
|
||||||
{
|
{
|
||||||
$this->theme_sombre = $theme_sombre;
|
$this->default_pwd = $default_pwd;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
@ -252,4 +267,47 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): ?bool
|
||||||
|
{
|
||||||
|
return $this->hasRole("ROLE_ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAncestorOf(User $user): bool
|
||||||
|
{
|
||||||
|
$sommet = $user->getCreator();
|
||||||
|
while ($sommet and $sommet!==$this) {
|
||||||
|
$sommet = $sommet->getCreator();
|
||||||
|
}
|
||||||
|
return $sommet===$this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBio(): ?string
|
||||||
|
{
|
||||||
|
return $this->bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBio(string $bio): static
|
||||||
|
{
|
||||||
|
$this->bio = $bio;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasURLpp(): bool
|
||||||
|
{
|
||||||
|
$uri_pp = $this->uri_pp;
|
||||||
|
if (!$this->uri_pp) return false;
|
||||||
|
return str_starts_with($uri_pp, 'https');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGlobalURLpp(): ?string{
|
||||||
|
if ($this->hasURLpp()) {
|
||||||
|
return $this->uri_pp;
|
||||||
|
}
|
||||||
|
if ($this->getUriPp()) {
|
||||||
|
return '/uploads/pp/' . $this->getUriPp();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
src/EventListener/ApiExceptionListener.php
Normal file
106
src/EventListener/ApiExceptionListener.php
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les exceptions de sécurité et redirections en réponses JSON pour l'API
|
||||||
|
*/
|
||||||
|
class ApiExceptionListener
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* IMPORTANT: Exécuté TRÈS tôt pour forcer le chargement de la session
|
||||||
|
*/
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
// Seulement pour /api
|
||||||
|
if (!str_starts_with($request->getPathInfo(), '/api')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S'assurer que la session est chargée et disponible
|
||||||
|
// Cela permet au firewall /api d'accéder à la session du context partagé
|
||||||
|
try {
|
||||||
|
if ($request->hasSession()) {
|
||||||
|
$session = $request->getSession();
|
||||||
|
// Forcer le chargement/démarrage de la session
|
||||||
|
if (!$session->isStarted()) {
|
||||||
|
$session->start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignorer les erreurs de session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelException(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$exception = $event->getThrowable();
|
||||||
|
|
||||||
|
// Vérifier si c'est une route /api
|
||||||
|
if (!str_starts_with($request->getPathInfo(), '/api')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = null;
|
||||||
|
|
||||||
|
if ($exception instanceof AuthenticationException) {
|
||||||
|
// Non authentifié
|
||||||
|
$response = new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => 'Authentication required',
|
||||||
|
],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
} elseif ($exception instanceof AccessDeniedException) {
|
||||||
|
// Authentifié mais pas les droits
|
||||||
|
$response = new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
'message' => 'You do not have permission to access this resource',
|
||||||
|
],
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response !== null) {
|
||||||
|
$event->setResponse($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepter les réponses de redirection sur les routes /api
|
||||||
|
* et les convertir en 401 JSON
|
||||||
|
*/
|
||||||
|
public function onKernelResponse(ResponseEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$response = $event->getResponse();
|
||||||
|
|
||||||
|
// Vérifier si c'est une route /api ET une redirection (302, 301, 303, 307, 308)
|
||||||
|
if (str_starts_with($request->getPathInfo(), '/api') &&
|
||||||
|
in_array($response->getStatusCode(), [301, 302, 303, 307, 308])) {
|
||||||
|
|
||||||
|
// Remplacer la redirection par une réponse 401 JSON
|
||||||
|
$jsonResponse = new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => 'Authentication required. Please provide a valid API token or be logged in.',
|
||||||
|
],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
$event->setResponse($jsonResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/EventListener/ApiForceSessionListener.php
Normal file
36
src/EventListener/ApiForceSessionListener.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force le chargement de la session pour les routes /api
|
||||||
|
* Exécuté très tôt dans le cycle de requête
|
||||||
|
*/
|
||||||
|
class ApiForceSessionListener
|
||||||
|
{
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
// Seulement pour les routes /api
|
||||||
|
if (!str_starts_with($request->getPathInfo(), '/api')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Forcer l'initialisation de la session
|
||||||
|
// Cela garantit que la session existante sera chargée
|
||||||
|
// avant que le firewall n'essaie de faire son travail
|
||||||
|
try {
|
||||||
|
$session = $request->getSession();
|
||||||
|
// L'accès à la session force son chargement/initialisation
|
||||||
|
if ($session->has('_sf2_attributes')) {
|
||||||
|
// Session existe et contient des données
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignorer les erreurs de session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
39
src/EventSubscriber/AccessDeniedSubscriber.php
Normal file
39
src/EventSubscriber/AccessDeniedSubscriber.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
|
||||||
|
|
||||||
|
class AccessDeniedSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
KernelEvents::EXCEPTION => ['onKernelException', 10],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelException(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
$exception = $event->getThrowable();
|
||||||
|
|
||||||
|
// Intercepter les exceptions d'authentification insuffisante (utilisateur non connecté)
|
||||||
|
if ($exception instanceof InsufficientAuthenticationException) {
|
||||||
|
// Rediriger vers la page d'accueil au lieu de /login
|
||||||
|
$event->setResponse(new RedirectResponse('/'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercepter les exceptions d'accès refusé (utilisateur connecté mais sans rôle)
|
||||||
|
if ($exception instanceof AccessDeniedException) {
|
||||||
|
// Rediriger vers la page d'accueil
|
||||||
|
$event->setResponse(new RedirectResponse('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
32
src/EventSubscriber/LogoutSubscriber.php
Normal file
32
src/EventSubscriber/LogoutSubscriber.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||||
|
|
||||||
|
class LogoutSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
LogoutEvent::class => 'onLogout',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onLogout(LogoutEvent $event): void
|
||||||
|
{
|
||||||
|
// Récupérer l'URL referer
|
||||||
|
$referer = $event->getRequest()->headers->get('referer');
|
||||||
|
|
||||||
|
// Vérifier que le referer n'est pas une URL de logout ou login
|
||||||
|
if ($referer && !str_contains($referer, '/logout') && !str_contains($referer, '/login')) {
|
||||||
|
$event->setResponse(new RedirectResponse($referer));
|
||||||
|
} else {
|
||||||
|
// Sinon rediriger vers la page d'accueil
|
||||||
|
$event->setResponse(new RedirectResponse('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
91
src/Form/Mod/ModType.php
Normal file
91
src/Form/Mod/ModType.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\Mod;
|
||||||
|
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Repository\ModRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
class ModType extends AbstractType
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ModRepository $modRepository) {}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$placeholderModNames = [
|
||||||
|
'Create',
|
||||||
|
'Ars Nouveau',
|
||||||
|
'Oculus',
|
||||||
|
'JEI',
|
||||||
|
];
|
||||||
|
$isEdit = $options['data']?->getId() !== null;
|
||||||
|
$mods = [];
|
||||||
|
if ($isEdit) {
|
||||||
|
$mods = $this->modRepository->findAvailableDependances($builder->getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->add('nom', TextType::class, [
|
||||||
|
'disabled' => $isEdit,
|
||||||
|
'label' => 'Nom du mod',
|
||||||
|
'required' => true,
|
||||||
|
'attr' => [
|
||||||
|
'placeholder' => 'Ex: '.$placeholderModNames[array_rand($placeholderModNames)],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('version', TextType::class, [
|
||||||
|
'label' => 'Version du mod',
|
||||||
|
'required' => true,
|
||||||
|
])
|
||||||
|
->add('uri', FileType::class, [
|
||||||
|
'disabled' => $isEdit,
|
||||||
|
'mapped' => false,
|
||||||
|
'label' => 'URL du mod',
|
||||||
|
'constraints' => [
|
||||||
|
new Assert\File([
|
||||||
|
'maxSize' => '128M',
|
||||||
|
'maxSizeMessage' => 'Le fichier est trop volumineux ({{ size }} {{ suffix }}). La taille maximale autorisée est {{ limit }} {{ suffix }}.',
|
||||||
|
'mimeTypes' => [
|
||||||
|
'application/java-archive',
|
||||||
|
'application/x-java-archive',
|
||||||
|
'application/x-jar',
|
||||||
|
'application/zip', // JAR = ZIP, souvent détecté ainsi
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
],
|
||||||
|
'mimeTypesMessage' => 'Uniquement des fichiers JAR sont autorisés.',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
'required' => !$isEdit, // Obligatoire uniquement lors de la création
|
||||||
|
'attr' => [
|
||||||
|
'accept' => '.jar',
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('dependances', EntityType::class, [
|
||||||
|
'class' => Mod::class,
|
||||||
|
'choice_label' => function (Mod $mod) {
|
||||||
|
return $mod->getNom() . ' - ' . $mod->getVersion();},
|
||||||
|
'multiple' => true,
|
||||||
|
'required' => false,
|
||||||
|
'choices' => $mods,
|
||||||
|
'disabled' => !$isEdit,
|
||||||
|
'attr' => [
|
||||||
|
'id' => 'dependances',
|
||||||
|
'data-placeholder'=> $isEdit? 'Rechercher une dépendance...' : 'Ajouter une dependance après la création du mod',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Mod::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Form/Modpack/ModpackDownloadType.php
Normal file
45
src/Form/Modpack/ModpackDownloadType.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\Modpack;
|
||||||
|
|
||||||
|
use App\Entity\Constitue;
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Entity\Modpack;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class ModpackDownloadType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
/** @var Modpack $modpack */
|
||||||
|
$modpack = $options['data'];
|
||||||
|
|
||||||
|
$optionnels = $modpack->getConstitues()
|
||||||
|
->filter(fn(Constitue $c) => $c->isOptionnel())
|
||||||
|
->map(fn(Constitue $c) => $c->getMod())
|
||||||
|
->getValues();
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->add('mods_optionnels', EntityType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'class' => Mod::class,
|
||||||
|
'choice_label' => fn(Mod $mod) => $mod->getNom() . ' - ' . $mod->getVersion(),
|
||||||
|
'choices' => $optionnels,
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'label' => 'Mods optionnels',
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Modpack::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Form/Modpack/ModpackType.php
Normal file
65
src/Form/Modpack/ModpackType.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\Modpack;
|
||||||
|
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Entity\Modpack;
|
||||||
|
use App\Repository\ModRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class ModpackType extends AbstractType
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ModRepository $modRepository) {}
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$isEdit = $options['data']?->getId() !== null;
|
||||||
|
|
||||||
|
$mods = $this->modRepository->findBy([], ['nom' => 'ASC', 'version' => 'ASC']);
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->add('version',TextType::class,[
|
||||||
|
'label' => 'Version',
|
||||||
|
])
|
||||||
|
->add('downloadable', CheckboxType::class, [
|
||||||
|
'label' => 'Téléchargeable',
|
||||||
|
'required' => false,
|
||||||
|
'disabled' => !$isEdit,
|
||||||
|
])
|
||||||
|
->add('mods', EntityType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'class' => Mod::class,
|
||||||
|
'choice_label' => function (Mod $mod) {
|
||||||
|
return $mod->getNom() . ' - ' . $mod->getVersion();},
|
||||||
|
'choices' => $mods,
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'label' => 'Mods',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
->add('mods_optionnels', EntityType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'class' => Mod::class,
|
||||||
|
'choice_label' => fn(Mod $mod) => $mod->getNom() . ' - ' . $mod->getVersion(),
|
||||||
|
'choices' => $mods,
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'label' => false,
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Modpack::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/Form/User/Settings/ProfileType.php
Normal file
82
src/Form/User/Settings/ProfileType.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\User\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
class ProfileType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('pseudo', TextType::class, [
|
||||||
|
'disabled' => true,
|
||||||
|
'label' => 'Pseudo',
|
||||||
|
/*'constraints' => [
|
||||||
|
new Assert\NotBlank(),
|
||||||
|
new Assert\Length(min: 3, max: 30),
|
||||||
|
new Assert\Regex(
|
||||||
|
pattern: '/^[\w\-\.]+$/u',
|
||||||
|
message: 'Le pseudo ne peut contenir que des lettres, chiffres, tirets et points.'
|
||||||
|
),
|
||||||
|
],*/
|
||||||
|
])
|
||||||
|
->add('bio', TextareaType::class, [
|
||||||
|
'label' => 'Bio',
|
||||||
|
'required' => false,
|
||||||
|
'empty_data' => '',
|
||||||
|
'constraints' => [
|
||||||
|
new Assert\Length(max: 500),
|
||||||
|
],
|
||||||
|
'attr' => ['maxlength' => 500, 'rows' => 4],
|
||||||
|
])
|
||||||
|
// url
|
||||||
|
->add('uri_pp', UrlType::class, [
|
||||||
|
'label' => 'URL de la photo de profil',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'default_protocol' => 'https',
|
||||||
|
'empty_data' => '',
|
||||||
|
'constraints' => [
|
||||||
|
new Assert\Url(
|
||||||
|
protocols: ['https'],
|
||||||
|
message: 'URL incorrecte',
|
||||||
|
),
|
||||||
|
new Assert\Regex(
|
||||||
|
pattern: '/^https:\/\/([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}(\/.*)?$/',
|
||||||
|
message: 'URL incorrecte (ex : https://monsite.com)',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
// fichier
|
||||||
|
->add('profile_picture_file', FileType::class, [
|
||||||
|
'label' => 'Ou uploader une image',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'constraints' => [
|
||||||
|
new Assert\Image([
|
||||||
|
'maxSize' => '2M',
|
||||||
|
'mimeTypes' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||||
|
'mimeTypesMessage' => 'Format accepté : JPG, PNG ou WebP.',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
'attr' => ['accept' => 'image/jpeg, image/png, image/webp'],
|
||||||
|
'help' => 'PNG, JPG ou WEBP — 2 Mo maximum'
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => \App\Entity\User::class, // ← manquant
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Form/User/Settings/SecurityType.php
Normal file
52
src/Form/User/Settings/SecurityType.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\User\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
class SecurityType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('current_password', PasswordType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
"label" => "Mot de passe actuel",
|
||||||
|
"required" => true,
|
||||||
|
])
|
||||||
|
->add('new_password', PasswordType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
"label" => "Nouveau mot de passe",
|
||||||
|
"required" => true,
|
||||||
|
"constraints" => [
|
||||||
|
new Assert\Length([
|
||||||
|
'min' => 8,
|
||||||
|
'minMessage' => 'Votre mot de passe doit comporter au moins {{ limit }} caractères.',
|
||||||
|
]),
|
||||||
|
new Assert\Regex([
|
||||||
|
'pattern' => '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]{8,}$/',
|
||||||
|
'message' => 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre.',
|
||||||
|
]),
|
||||||
|
new Assert\NotBlank([
|
||||||
|
'message' => 'Le mot de passe ne peut pas être vide.',
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('new_password_confirmation', PasswordType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'label' => 'Confirmer le nouveau mot de passe',
|
||||||
|
'required' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
// Configure your form options here
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/Form/User/UserFormType.php
Normal file
96
src/Form/User/UserFormType.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\User;
|
||||||
|
|
||||||
|
use App\Entity\Joueur;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Callback;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
|
class UserFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$isEdit = $options['data']?->getId() !== null;
|
||||||
|
$builder
|
||||||
|
->add('pseudo', TextType::class ,[
|
||||||
|
"label" => "Pseudo",
|
||||||
|
"required" => true,
|
||||||
|
"disabled" => $isEdit,
|
||||||
|
"attr" => [
|
||||||
|
"placeholder" => "Pseudo",
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('roles', ChoiceType::class, [
|
||||||
|
"label" => "Roles",
|
||||||
|
'choices' => [
|
||||||
|
'Admin' => 'ROLE_ADMIN',
|
||||||
|
'Joueur' => 'ROLE_JOUEUR',
|
||||||
|
'Visiteur' => 'ROLE_VISITEUR',
|
||||||
|
],
|
||||||
|
'expanded' => true,
|
||||||
|
'multiple' => true,
|
||||||
|
'required' => false,
|
||||||
|
'constraints' => [
|
||||||
|
new Callback(function (array $roles, ExecutionContextInterface $context) {
|
||||||
|
$mutuallyExclusive = ['ROLE_B', 'ROLE_C'];
|
||||||
|
|
||||||
|
// Intersection entre les rôles sélectionnés et les rôles exclusifs
|
||||||
|
$found = array_intersect($roles, $mutuallyExclusive);
|
||||||
|
|
||||||
|
if (count($found) > 1) {
|
||||||
|
$context
|
||||||
|
->buildViolation('Les rôles B et C ne peuvent pas être attribués ensemble.')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('password', PasswordType::class, [
|
||||||
|
"label" => "Mot de passe",
|
||||||
|
'mapped' => false,
|
||||||
|
"required" => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'new-password',
|
||||||
|
"placeholder" => "Mot de passe, laissez vide pour mot de passe par defaut",
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('joueur', EntityType::class, [
|
||||||
|
"label" => "Joueur associé",
|
||||||
|
'class' => Joueur::class,
|
||||||
|
'choice_label' => 'pseudo',
|
||||||
|
"required" => false,
|
||||||
|
"multiple" => false,
|
||||||
|
"query_builder" => function (EntityRepository $er) use ($options) {
|
||||||
|
$qb = $er->createQueryBuilder('j')
|
||||||
|
->leftJoin('j.owner', 'u')
|
||||||
|
->where('u.id IS NULL');
|
||||||
|
|
||||||
|
// En mode édition, inclure le joueur déjà associé
|
||||||
|
$currentJoueur = $options['data']?->getJoueur();
|
||||||
|
if ($currentJoueur) {
|
||||||
|
$qb->orWhere('j.id = :current')
|
||||||
|
->setParameter('current', $currentJoueur->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->orderBy('j.pseudo', 'ASC');
|
||||||
|
}
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => User::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Repository/ApiKeyRepository.php
Normal file
54
src/Repository/ApiKeyRepository.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ApiKey;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ApiKey>
|
||||||
|
*/
|
||||||
|
class ApiKeyRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ApiKey::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une clé API valide par token
|
||||||
|
*/
|
||||||
|
public function findValidByToken(string $token): ?ApiKey
|
||||||
|
{
|
||||||
|
$apiKey = $this->findOneBy(['token' => $token]);
|
||||||
|
|
||||||
|
if (!$apiKey || !$apiKey->isValid()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve toutes les clés API d'un utilisateur
|
||||||
|
*/
|
||||||
|
public function findByUser($user): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['user' => $user], ['createdAt' => 'DESC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime les clés API expirées
|
||||||
|
*/
|
||||||
|
public function deleteExpiredKeys(): int
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('ak')
|
||||||
|
->delete()
|
||||||
|
->where('ak.expiresAt < :now')
|
||||||
|
->setParameter('now', new \DateTime())
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -16,6 +16,52 @@ class ModRepository extends ServiceEntityRepository
|
||||||
parent::__construct($registry, Mod::class);
|
parent::__construct($registry, Mod::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les mods qui ne sont pas une dépendance (directe ou indirecte)
|
||||||
|
* du mod passé en paramètre, et qui n'est pas le mod lui-même.
|
||||||
|
*/
|
||||||
|
public function findAvailableDependances(Mod $mod): array
|
||||||
|
{
|
||||||
|
$excludedIds = $this->collectDependencyIds($mod, []);
|
||||||
|
$excludedIds[] = $mod->getId();
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('m');
|
||||||
|
|
||||||
|
if (!empty($excludedIds)) {
|
||||||
|
$qb->where('m.id NOT IN (:excludedIds)')
|
||||||
|
->setParameter('excludedIds', $excludedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collecte récursivement les IDs de toutes les dépendances d'un mod.
|
||||||
|
*
|
||||||
|
* @param int[] $visited IDs déjà visités (évite les cycles infinis)
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function collectDependencyIds(Mod $mod, array $visited): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ($mod->getDependances() as $dependance) {
|
||||||
|
$depId = $dependance->getId();
|
||||||
|
|
||||||
|
// Evite les boucles infinies en cas de dépendances cycliques
|
||||||
|
if (in_array($depId, $visited, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visited[] = $depId;
|
||||||
|
$ids[] = $depId;
|
||||||
|
$ids = array_merge($ids, $this->collectDependencyIds($dependance, $visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * @return Mod[] Returns an array of Mod objects
|
// * @return Mod[] Returns an array of Mod objects
|
||||||
// */
|
// */
|
||||||
|
|
|
||||||
19
src/Security/AccessDeniedHandler.php
Normal file
19
src/Security/AccessDeniedHandler.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
|
||||||
|
|
||||||
|
class AccessDeniedHandler implements AccessDeniedHandlerInterface
|
||||||
|
{
|
||||||
|
public function handle(Request $request, AccessDeniedException $accessDeniedException): Response
|
||||||
|
{
|
||||||
|
// Rediriger vers la page d'accueil
|
||||||
|
return new RedirectResponse('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
27
src/Security/ApiAccessDeniedHandler.php
Normal file
27
src/Security/ApiAccessDeniedHandler.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestionnaire d'accès refusé personnalisé pour l'API
|
||||||
|
* Retourne des réponses JSON au lieu de rediriger
|
||||||
|
*/
|
||||||
|
class ApiAccessDeniedHandler implements AccessDeniedHandlerInterface
|
||||||
|
{
|
||||||
|
public function handle(Request $request, AccessDeniedException $accessDeniedException): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
'message' => 'You do not have permission to access this resource',
|
||||||
|
],
|
||||||
|
403 // HTTP 403 Forbidden
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
27
src/Security/ApiAuthenticationEntryPoint.php
Normal file
27
src/Security/ApiAuthenticationEntryPoint.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point d'entrée personnalisé pour l'API
|
||||||
|
* Retourne des réponses JSON au lieu de rediriger
|
||||||
|
*/
|
||||||
|
class ApiAuthenticationEntryPoint implements AuthenticationEntryPointInterface
|
||||||
|
{
|
||||||
|
public function start(Request $request, AuthenticationException $authException = null): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => 'Authentication required',
|
||||||
|
],
|
||||||
|
401 // HTTP 401 Unauthorized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
60
src/Security/ApiBearerTokenAuthenticator.php
Normal file
60
src/Security/ApiBearerTokenAuthenticator.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticateur pour les tokens API (Bearer tokens)
|
||||||
|
* Utilisé uniquement si un header Authorization est présent
|
||||||
|
* Laisse la session tranquille si pas de token
|
||||||
|
*/
|
||||||
|
class ApiBearerTokenAuthenticator extends AbstractAuthenticator
|
||||||
|
{
|
||||||
|
public function __construct(private ApiTokenHandler $tokenHandler)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
// Seulement si c'est un Bearer token
|
||||||
|
return $request->headers->has('Authorization') &&
|
||||||
|
str_starts_with($request->headers->get('Authorization', ''), 'Bearer ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$authHeader = $request->headers->get('Authorization');
|
||||||
|
$token = substr($authHeader, 7); // Enlever "Bearer "
|
||||||
|
|
||||||
|
// Valider le token
|
||||||
|
$userBadge = $this->tokenHandler->getUserBadgeFrom($token);
|
||||||
|
return new SelfValidatingPassport($userBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?JsonResponse
|
||||||
|
{
|
||||||
|
// Laisser la requête continuer
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => 'Invalid or expired API token',
|
||||||
|
],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
src/Security/ApiTokenHandler.php
Normal file
33
src/Security/ApiTokenHandler.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\ApiKey;
|
||||||
|
use App\Repository\ApiKeyRepository;
|
||||||
|
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||||
|
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
|
||||||
|
class ApiTokenHandler implements AccessTokenHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(private ApiKeyRepository $apiKeyRepository)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserBadgeFrom(string $credentials): UserBadge
|
||||||
|
{
|
||||||
|
$apiKey = $this->apiKeyRepository->findValidByToken($credentials);
|
||||||
|
|
||||||
|
if (!$apiKey) {
|
||||||
|
throw new BadCredentialsException('Invalid API token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le dernier accès
|
||||||
|
$apiKey->setLastUsedAt(new \DateTime());
|
||||||
|
$this->apiKeyRepository->getEntityManager()->flush();
|
||||||
|
|
||||||
|
return new UserBadge($apiKey->getUser()->getPseudo());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
src/Security/ApiUserProvider.php
Normal file
56
src/Security/ApiUserProvider.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
|
||||||
|
class ApiUserProvider implements UserProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(private UserRepository $userRepository)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recharger l'utilisateur par pseudo (utilisé par le firewall)
|
||||||
|
*/
|
||||||
|
public function loadUserByIdentifier(string $identifier): UserInterface
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findOneBy(['pseudo' => $identifier]);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new UserNotFoundException(sprintf('User "%s" not found', $identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier si ce provider supporte la classe utilisateur
|
||||||
|
*/
|
||||||
|
public function supportsClass(string $class): bool
|
||||||
|
{
|
||||||
|
return $class === 'App\Entity\User';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rafraîchir l'utilisateur (recharger depuis la BD)
|
||||||
|
*/
|
||||||
|
public function refreshUser(UserInterface $user): UserInterface
|
||||||
|
{
|
||||||
|
if (!$this->supportsClass($user::class)) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('Instances of "%s" are not supported', $user::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedUser = $this->userRepository->find($user->getId());
|
||||||
|
|
||||||
|
if (!$updatedUser) {
|
||||||
|
throw new UserNotFoundException(sprintf('User "%d" not found', $user->getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updatedUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
34
src/Security/LoginFailureHandler.php
Normal file
34
src/Security/LoginFailureHandler.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Http\HttpUtils;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class LoginFailureHandler implements AuthenticationFailureHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpUtils $httpUtils,
|
||||||
|
private readonly ?LoggerInterface $logger = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||||
|
{
|
||||||
|
// Récupérer la session et ajouter un message flash
|
||||||
|
$session = $request->getSession();
|
||||||
|
$session->getFlashBag()->add('error', 'Alors, Tu as PERDU (tes identifiants) ?');
|
||||||
|
|
||||||
|
// Rediriger vers la page de référence ou vers la racine
|
||||||
|
$targetUrl = $request->headers->get('referer') ?? $this->httpUtils->generateUrl($request, 'app_login');
|
||||||
|
|
||||||
|
return $this->httpUtils->createRedirectResponse($request, $targetUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
38
src/Security/LoginSuccessHandler.php
Normal file
38
src/Security/LoginSuccessHandler.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Http\HttpUtils;
|
||||||
|
|
||||||
|
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpUtils $httpUtils,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
|
||||||
|
{
|
||||||
|
// Récupérer la session et ajouter un message flash
|
||||||
|
$session = $request->getSession();
|
||||||
|
$user = $token->getUser();
|
||||||
|
$session->getFlashBag()->add('info', 'Bon retour ' . $user->getPseudo() . ' !');
|
||||||
|
if ($user->hasDefaultPwd()) {
|
||||||
|
$session->getFlashBag()->add('warning', 'Vous utilisez un mot de passe par défaut, pensez à le changer dans les paramètres de votre compte !');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rediriger vers le chemin cible défini (ou la page par défaut)
|
||||||
|
if ($targetPath = $request->getSession()->get('_security.main.target_path')) {
|
||||||
|
$request->getSession()->remove('_security.main.target_path');
|
||||||
|
return new RedirectResponse($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RedirectResponse($this->httpUtils->generateUri($request, 'app_accueil'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
27
src/Security/LogoutSuccessHandler.php
Normal file
27
src/Security/LogoutSuccessHandler.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
|
||||||
|
class LogoutSuccessHandler implements LogoutSuccessHandlerInterface
|
||||||
|
{
|
||||||
|
public function onLogoutSuccess(Request $request, TokenInterface $token): Response
|
||||||
|
{
|
||||||
|
// Essayer de récupérer l'URL referer
|
||||||
|
$referer = $request->headers->get('referer');
|
||||||
|
|
||||||
|
// Vérifier que le referer n'est pas une URL de logout ou login
|
||||||
|
if ($referer && !str_contains($referer, '/logout') && !str_contains($referer, '/login')) {
|
||||||
|
return new RedirectResponse($referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon rediriger vers la page d'accueil
|
||||||
|
return new RedirectResponse('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
37
src/Service/ModpackZipService.php
Normal file
37
src/Service/ModpackZipService.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Mod;
|
||||||
|
use App\Entity\Modpack;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
|
||||||
|
class ModpackZipService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $tmpDir,
|
||||||
|
private readonly UploaderService $uploaderService,) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Mod[] $mods
|
||||||
|
*/
|
||||||
|
public function createZip(Modpack $modpack, array $mods): string
|
||||||
|
{
|
||||||
|
if (!is_dir($this->tmpDir)) {
|
||||||
|
mkdir($this->tmpDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipPath = $this->tmpDir . '/modpack-' . $modpack->getId() . '.zip';
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||||
|
|
||||||
|
foreach ($mods as $mod) {
|
||||||
|
$modPath = $this->uploaderService->getUploadDir(UploaderService::DIR_MOD) . '/' . $mod->getUri() ; // chemin réel vers le fichier
|
||||||
|
$zip->addFile($modPath, $mod->getNom() . '-' . $mod->getVersion() . '.jar');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Service/UploaderService.php
Normal file
54
src/Service/UploaderService.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
class UploaderService
|
||||||
|
{
|
||||||
|
public const DIR_PP = 'pp';
|
||||||
|
public const DIR_MOD = 'mods';
|
||||||
|
public const DIR_FILE = 'files';
|
||||||
|
|
||||||
|
public function __construct(private readonly string $uploadDir) {}
|
||||||
|
|
||||||
|
public function getUploadDir(string $sousDir = self::DIR_FILE): string
|
||||||
|
{
|
||||||
|
return $this->uploadDir . '/' . $sousDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(UploadedFile $file, string $sousDir = self::DIR_FILE, bool $keepOriginalName = false): string
|
||||||
|
{
|
||||||
|
$dossier = $this->uploadDir . '/' . $sousDir;
|
||||||
|
|
||||||
|
if (!is_dir($dossier)) {
|
||||||
|
mkdir($dossier, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keepOriginalName) {
|
||||||
|
// Garde le nom original du fichier
|
||||||
|
$nom = $file->getClientOriginalName();
|
||||||
|
if (file_exists($dossier . '/' . $nom)) {
|
||||||
|
throw new \RuntimeException("Le mod '$nom' existe déjà.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Génère un nom unique (comportement par défaut)
|
||||||
|
$nom = uniqid() . '.' . $file->guessExtension();
|
||||||
|
}
|
||||||
|
|
||||||
|
$file->move($dossier, $nom);
|
||||||
|
|
||||||
|
return $nom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $nom, string $sousDir = self::DIR_FILE): ?bool
|
||||||
|
{
|
||||||
|
$chemin = $this->uploadDir . '/' . $sousDir . '/' . $nom;
|
||||||
|
|
||||||
|
if (file_exists($chemin)) {
|
||||||
|
return unlink($chemin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Fichier non trouvé
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,49 @@
|
||||||
|
|
||||||
{% block title %}Administration{% endblock %}
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block stylesheets %}
|
||||||
<main>
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
</main>
|
<link rel="stylesheet" href="{{ asset('styles/administration/index.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<a href="{{ path("app_admin_modpacks") }}" class="bulle admin-card">
|
||||||
|
<span class="admin-card__icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5 7.5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v.938l.4 1.599a1 1 0 0 1-.416 1.074l-.93.62a1 1 0 0 1-1.11 0l-.929-.62a1 1 0 0 1-.415-1.074L5 8.438zm2 0H6v.938a1 1 0 0 1-.03.243l-.4 1.598.93.62.929-.62-.4-1.598A1 1 0 0 1 7 8.438z"/>
|
||||||
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1h-2v1h-1v1h1v1h-1v1h1v1H6V5H5V4h1V3H5V2h1V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="admin-card__title">MODPACK</span>
|
||||||
|
<span class="admin-card__desc">selection des mods</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ path("app_admin_mods") }}" class="bulle admin-card">
|
||||||
|
<span class="admin-card__icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM1.521 15.175a1.3 1.3 0 0 1-.082-.466h.765a.6.6 0 0 0 .073.27.5.5 0 0 0 .454.246q.285 0 .422-.164.138-.165.138-.466V11.85h.79v2.725q0 .66-.357 1.005-.354.345-.984.345a1.6 1.6 0 0 1-.568-.094 1.1 1.1 0 0 1-.408-.266 1.1 1.1 0 0 1-.243-.39m3.972-.354-.314 1.028h-.8l1.342-3.999h.926l1.336 3.999h-.84l-.314-1.028zm1.178-.59-.49-1.616h-.035l-.49 1.617zm2.342 1.618h.952l1.327-3.999h-.878l-.888 3.138h-.038L8.59 11.85h-.917zm3.087-1.028-.314 1.028h-.8l1.342-3.999h.926l1.336 3.999h-.84l-.314-1.028zm1.178-.59-.49-1.616h-.035l-.49 1.617z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="admin-card__title">MODS</span>
|
||||||
|
<span class="admin-card__desc">gestion des mods</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ path("app_admin_users") }}" class="bulle admin-card">
|
||||||
|
<span class="admin-card__icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1zm-7.978-1L7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0M6.936 9.28a6 6 0 0 0-1.23-.247A7 7 0 0 0 5 9c-4 0-5 3-5 4q0 1 1 1h4.216A2.24 2.24 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816M4.92 10A5.5 5.5 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0m3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="admin-card__title">USERS</span>
|
||||||
|
<span class="admin-card__desc">gestion des utilisateurs</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
82
templates/administration/modpacks/edit.html.twig
Normal file
82
templates/administration/modpacks/edit.html.twig
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% set optFields = [] %}
|
||||||
|
{% for opt in form.mods_optionnels %}
|
||||||
|
{% set optFields = optFields|merge([opt]) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Mods</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.version, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.version) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-toggle">
|
||||||
|
{{ form_widget(form.downloadable) }}
|
||||||
|
<span class="form-toggle__track"></span>
|
||||||
|
<span class="form-toggle__label {% if form.downloadable.vars.disabled %}disabled{% endif %}">Téléchargeable</span>
|
||||||
|
</label>
|
||||||
|
{{ form_errors(form.downloadable) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.mods, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
<div class="form-checkbox-group"
|
||||||
|
data-controller="mod-optional">
|
||||||
|
|
||||||
|
{% for mod in form.mods %}
|
||||||
|
|
||||||
|
<div class="form-wrapper__row {% if not loop.last %}form-wrapper__row-line{% endif %}"
|
||||||
|
data-mod-optional-target="row">
|
||||||
|
|
||||||
|
<label class="form-checkbox-group-item">
|
||||||
|
{{ form_widget(mod, { attr: {
|
||||||
|
'data-mod-checkbox': '',
|
||||||
|
'data-action': 'change->mod-optional#toggle'
|
||||||
|
}}) }}
|
||||||
|
<span>{{ form_label(mod) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-toggle d-none"
|
||||||
|
data-opt-toggle>
|
||||||
|
{{ form_widget(optFields[loop.index0], { attr: { 'data-ui-opt': '' } }) }}
|
||||||
|
<span class="form-toggle__track"></span>
|
||||||
|
<span class="form-toggle__label">Optionnel</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{ form_errors(form.mods) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<a href="{{ path('app_admin_modpacks') }}" class="form-btn form-btn--cancel">Annuler</a>
|
||||||
|
<button type="submit" class="form-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
50
templates/administration/modpacks/index.html.twig
Normal file
50
templates/administration/modpacks/index.html.twig
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/administration/modpacks/index.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Modpacks</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
|
||||||
|
{# ADD BUTTON #}
|
||||||
|
<a href="{{ path("app_admin_modpacks_add") }}" class="bulle bulle-all-col modpack-card">
|
||||||
|
<span class="modpack-add-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="modpack-name">
|
||||||
|
Créer un modpack
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% for modpack in modpacks %}
|
||||||
|
<div class="bulle bulle-all-col modpack-card"
|
||||||
|
data-controller="clickable-card"
|
||||||
|
data-href="{{ path('app_admin_modpacks_edit', {'id': modpack.id}) }}"
|
||||||
|
data-action="click->clickable-card#navigate">
|
||||||
|
|
||||||
|
<div class="modpack-name">{{ modpack.version }}</div>
|
||||||
|
<div class="modpack-space"></div>
|
||||||
|
<div class="modpack-number">{{ modpack.numberofmods }} mod{% if modpack.numberofmods>=2 %}s{% endif %}</div>
|
||||||
|
<div class="modpack-used {% if not modpack.downloadable %}non-utilise{% endif %}">
|
||||||
|
{% if modpack.downloadable %}Téléchargeable{% else %}Caché{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ path('app_admin_modpacks_delete', {'id' : modpack.id}) }}" class="modpack-delete" data-action="click->clickable-card#stopPropagation">Supprimer</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
58
templates/administration/mods/edit.html.twig
Normal file
58
templates/administration/mods/edit.html.twig
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Mods</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.nom, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.nom, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.nom) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.version, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.version) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.uri, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.uri, { attr: { class: 'form-file' } }) }}
|
||||||
|
{{ form_errors(form.uri) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.dependances, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.dependances, { attr: {
|
||||||
|
class: 'form-tom-select-input',
|
||||||
|
'data-controller': 'tom-select',
|
||||||
|
|
||||||
|
} } ) }}
|
||||||
|
{{ form_errors(form.dependances) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<a href="{{ path('app_admin_mods') }}" class="form-btn form-btn--cancel">Annuler</a>
|
||||||
|
<button type="submit" class="form-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
60
templates/administration/mods/index.html.twig
Normal file
60
templates/administration/mods/index.html.twig
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/administration/mods/index.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Mods</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
|
||||||
|
{# ADD BUTTON #}
|
||||||
|
<a href="{{ path("app_admin_mods_add") }}" class="bulle bulle-all-col mod-card">
|
||||||
|
<span class="mod-add-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="mod-name">
|
||||||
|
Ajouter un mod
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% for mod in mods %}
|
||||||
|
<div class="bulle bulle-all-col mod-card"
|
||||||
|
data-controller="clickable-card"
|
||||||
|
data-href="{{ path('app_admin_mods_edit', {'idmod': mod.id}) }}"
|
||||||
|
data-action="click->clickable-card#navigate">
|
||||||
|
|
||||||
|
<div class="mod-name">{{ mod.nom }}</div>
|
||||||
|
<div class="mod-space"></div>
|
||||||
|
<div class="mod-version"> Version : {{ mod.version }}</div>
|
||||||
|
|
||||||
|
<div class="mod-deps">
|
||||||
|
{% for dependance in mod.dependances %}
|
||||||
|
<a href='{{ path('app_admin_mods_edit', {'idmod': dependance.id}) }}'
|
||||||
|
class="mod-dep" data-action="click->mod-card#stopPropagation">
|
||||||
|
{{ dependance.nom }} - {{ dependance.version }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mod-used {% if not mod.isUsed %}non-utilise{% endif %}">
|
||||||
|
{% if mod.isUsed %}Utilisé{% else %}Non utilisé{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ path('app_admin_mods_delete', {'idmod': mod.id}) }}" class="mod-delete" data-action="click->clickable-card#stopPropagation">Supprimer</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
71
templates/administration/users/edit.html.twig
Normal file
71
templates/administration/users/edit.html.twig
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Users</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
|
||||||
|
|
||||||
|
<div class="form-wrapper__row">
|
||||||
|
|
||||||
|
{# Colonne gauche : pseudo + password + joueur #}
|
||||||
|
<div class="form-wrapper__col">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.pseudo, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.pseudo, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.pseudo) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.password, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.password, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.password) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.joueur, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.joueur, { attr: { class: 'form-input form-select' } }) }}
|
||||||
|
{{ form_errors(form.joueur) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Colonne droite : rôles + theme #}
|
||||||
|
<div class="form-wrapper__col">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.roles, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
<div class="form-checkbox-group">
|
||||||
|
{% for role in form.roles %}
|
||||||
|
<label class="form-checkbox-group-item">
|
||||||
|
{{ form_widget(role) }}
|
||||||
|
<span>{{ form_label(role) }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form_errors(form.roles) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<a href="{{ path('app_admin_users') }}" class="form-btn form-btn--cancel">Annuler</a>
|
||||||
|
<button type="submit" class="form-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
73
templates/administration/users/index.html.twig
Normal file
73
templates/administration/users/index.html.twig
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/administration/users/index.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Administration : Users</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
|
||||||
|
{# ADD BUTTON #}
|
||||||
|
<a href="{{ path("app_admin_users_add") }}" class="bulle bulle-all-col user-card">
|
||||||
|
<span class="pp-svg-wrapper">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="pp-svg">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="user-pseudo">
|
||||||
|
Ajouter un utilisateur
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% for user in users %}
|
||||||
|
<a {% if app.user and app.user.isAncestorOf(user) %} href="{{ path("app_admin_users_edit", {'iduser':user.id}) }}" {% endif %}
|
||||||
|
class="bulle bulle-all-col user-card">
|
||||||
|
{% if user.uripp %}
|
||||||
|
<img src="{{ user.globalURLPP }}" alt="photo" class="pp-img">
|
||||||
|
{% else %}
|
||||||
|
<span class="pp-svg-wrapper">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="pp-svg">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="user-pseudo">
|
||||||
|
{{ user.pseudo }}
|
||||||
|
</div>
|
||||||
|
<div class="user-space"></div>
|
||||||
|
<div class="user-role">
|
||||||
|
{% if user.admin %}
|
||||||
|
admin
|
||||||
|
{% else %}
|
||||||
|
user
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="user-editable {% if not (app.user and app.user.isAncestorOf(user)) %}non-modifiable{% endif %}">
|
||||||
|
{% if app.user and app.user.isAncestorOf(user) %}
|
||||||
|
modifiable
|
||||||
|
{% else %}
|
||||||
|
non modifiable
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
<html data-theme="light" lang="fr">
|
<html data-theme="light" lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}CraftWorld{% endblock %}</title>
|
<title>{% block title %}Ploush{% endblock %}</title>
|
||||||
<link rel="icon" href="...">
|
<link rel="icon" href="...">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
{# Anti-flash - DOIT être avant tout le reste #}
|
{# Anti-flash - DOIT être avant tout le reste #}
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -20,6 +21,8 @@
|
||||||
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
|
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
|
||||||
{# Styles du header #}
|
{# Styles du header #}
|
||||||
<link rel="stylesheet" href="{{ asset('styles/partials/header.css') }}">
|
<link rel="stylesheet" href="{{ asset('styles/partials/header.css') }}">
|
||||||
|
{#Style des flash messages #}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/partials/flash.css') }}">
|
||||||
|
|
||||||
{% block stylesheets %}{% endblock %}
|
{% block stylesheets %}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -34,8 +37,23 @@
|
||||||
data-bg-dark="{{ asset('images/bg-dark.png') }}">
|
data-bg-dark="{{ asset('images/bg-dark.png') }}">
|
||||||
|
|
||||||
{% include 'partials/_header.html.twig' %}
|
{% include 'partials/_header.html.twig' %}
|
||||||
|
<main>
|
||||||
|
{% block titlePage %}{% endblock %}
|
||||||
|
|
||||||
{% block body %}{% endblock %}
|
{% block flash_messages %}
|
||||||
|
<div class="flash-container">
|
||||||
|
{% for label, messages in app.flashes %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="flash flash-{{ label }}">
|
||||||
|
<span class="flash-body">{{ message }}</span>
|
||||||
|
<button class="flash-close" onclick="this.parentElement.remove()" aria-label="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}{% endblock %}
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
57
templates/modpack/index.html.twig
Normal file
57
templates/modpack/index.html.twig
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} Modpack {% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/vitrine/index.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<section class="bulle bulle-all-col flex-column align-items-center">
|
||||||
|
<h1 class="text-center">Télécharger notre Modpack</h1>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="version-select">Version</label>
|
||||||
|
<select id="version-select"
|
||||||
|
class="form-input form-select"
|
||||||
|
data-controller="modpack-download"
|
||||||
|
data-action="change->modpack-download#changeVersion">
|
||||||
|
{% for mp in modpacks %}
|
||||||
|
<option value="{{ mp.id }}" {{ mp.id == modpack.id ? 'selected' : '' }}>
|
||||||
|
{{ mp.version }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ form_start(form, {
|
||||||
|
'attr': {
|
||||||
|
class: 'bulle bulle-all-col form-wrapper',
|
||||||
|
'data-controller': 'modpack-download',
|
||||||
|
'data-modpack-download-url-value': path('app_modpack_download', {'id': modpack.id}),
|
||||||
|
'data-action': 'submit->modpack-download#submit'
|
||||||
|
}
|
||||||
|
}) }}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.mods_optionnels, "", { label_attr: { class: 'form-label' } }) }}
|
||||||
|
<div class="form-checkbox-group">
|
||||||
|
{% for mod in form.mods_optionnels %}
|
||||||
|
<label class="form-checkbox-group-item">
|
||||||
|
{{ form_widget(mod) }}
|
||||||
|
<span>{{ form_label(mod) }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form_errors(form.mods_optionnels) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<button type="submit" class="form-btn">Télécharger</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
class="nav-link {{ current_route == 'app_accueil' ? 'active' }}">
|
class="nav-link {{ current_route == 'app_accueil' ? 'active' }}">
|
||||||
Accueil
|
Accueil
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('app_accueil') }}"
|
<a href="{{ path('app_modpack') }}"
|
||||||
class="nav-link {{ current_route == 'app_home' ? 'active' }}">
|
class="nav-link {{ current_route == 'app_modpack' ? 'active' }}">
|
||||||
Modpack
|
Modpack
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('app_accueil') }}"
|
<a href="{{ path('app_accueil') }}"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,16 @@
|
||||||
|
|
||||||
<span class="dropdown-title">Connexion</span>
|
<span class="dropdown-title">Connexion</span>
|
||||||
|
|
||||||
|
{# Afficher les messages d'erreur flash #}
|
||||||
|
{% set errors = app.flashes('error') %}
|
||||||
|
{% if errors %}
|
||||||
|
<div class="login-error">
|
||||||
|
{% for message in errors %}
|
||||||
|
<div class="alert alert-danger">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form action="{{ path('app_login') }}" method="post" >
|
<form action="{{ path('app_login') }}" method="post" >
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
|
|
@ -54,6 +64,11 @@
|
||||||
name="_csrf_token"
|
name="_csrf_token"
|
||||||
value="{{ csrf_token('authenticate') }}">
|
value="{{ csrf_token('authenticate') }}">
|
||||||
|
|
||||||
|
{# Rediriger vers la page courante après connexion #}
|
||||||
|
<input type="hidden"
|
||||||
|
name="_target_path"
|
||||||
|
value="{{ app.request.uri }}">
|
||||||
|
|
||||||
{# Retenir la page courante pour y revenir en cas d'erreur #}
|
{# Retenir la page courante pour y revenir en cas d'erreur #}
|
||||||
<input type="hidden"
|
<input type="hidden"
|
||||||
name="_failure_path"
|
name="_failure_path"
|
||||||
|
|
@ -63,7 +78,6 @@
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
<button class="login-btn" id="loginToggle"
|
<button class="login-btn" id="loginToggle"
|
||||||
aria-haspopup="true" aria-expanded="false"
|
aria-haspopup="true" aria-expanded="false"
|
||||||
data-header-dropdown-target="toggle">
|
data-header-dropdown-target="toggle">
|
||||||
{% if app.user.uriPp is not null %}
|
{% if app.user.globalURLpp is not null %}
|
||||||
<img src="{{ asset(app.user.uri_pp) }}" alt="Photo de profil de {{ app.user.pseudo }}" class="profile-pic">
|
<img src="{{ asset(app.user.globalURLpp) }}" alt="Photo de profil de {{ app.user.pseudo }}" class="profile-pic">
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24"
|
<svg width="15" height="15" viewBox="0 0 24 24"
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
|
@ -32,13 +32,13 @@
|
||||||
<span class="dropdown-title">Bonjour {{ app.user.pseudo }}</span>
|
<span class="dropdown-title">Bonjour {{ app.user.pseudo }}</span>
|
||||||
|
|
||||||
{% if is_granted("ROLE_ADMIN") %}
|
{% if is_granted("ROLE_ADMIN") %}
|
||||||
<a href="{{ path('app_administration') }}"
|
<a href="{{ path('app_admin') }}"
|
||||||
class="dropdown-link {{ current_route == 'app_administration' ? 'active' }}">
|
class="dropdown-link {{ current_route == 'app_administration' ? 'active' }}">
|
||||||
Administration
|
Administration
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{{ path('app_accueil') }}"
|
<a href="{{ path('app_settings') }}"
|
||||||
class="dropdown-link {{ current_route == 'app_home' ? 'active' }}">
|
class="dropdown-link {{ current_route == 'app_home' ? 'active' }}">
|
||||||
Parametre
|
Parametre
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>
|
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>
|
||||||
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
|
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
|
||||||
|
{% if app.request.headers.get('referer') %}
|
||||||
|
<input type="hidden" name="_target_path" value="{{ app.request.headers.get('referer') }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{#
|
{#
|
||||||
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
|
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
|
||||||
|
|
|
||||||
32
templates/settings/base.html.twig
Normal file
32
templates/settings/base.html.twig
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% set current_route = app.request.attributes.get('_route') %}
|
||||||
|
|
||||||
|
{% block title %}Parametre{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/navbutton.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block titlePage %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
<div class="bulle bulle-all-col bulle-nohover bulle-header">
|
||||||
|
<h1 class="bulle-title">Parametre : {{ app.user.pseudo }}</h1>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<nav class="bulle bulle-all-col bulle-nohover d-flex flex-nowrap flex-row justify-content-start align-items-center gap-2">
|
||||||
|
<a href="{{ path("app_settings_profile") }}" class="nav-link {{ current_route == 'app_settings_profile' ? 'active' }}">Profile</a>
|
||||||
|
<a href="{{ path("app_settings_security") }}" class="nav-link {{ current_route == 'app_settings_security' ? 'active' }}">Securité</a>
|
||||||
|
<div class="nav-link {{ current_route == 'app_settings_territory' ? 'active' }}">Territoires</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
{% block form %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
49
templates/settings/profile.html.twig
Normal file
49
templates/settings/profile.html.twig
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends 'settings/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="bulle-container">
|
||||||
|
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
|
||||||
|
|
||||||
|
<div class="form-wrapper__row">
|
||||||
|
<div class="form-wrapper__col">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.pseudo, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.pseudo, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.pseudo) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.bio, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.bio, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.bio) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-wrapper__col">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.uri_pp, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.uri_pp, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.uri_pp) }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.profile_picture_file, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.profile_picture_file, { attr: { class: 'form-file' } }) }}
|
||||||
|
{{ form_help(form.profile_picture_file, { help_attr: { class: 'form-helper' } } ) }}
|
||||||
|
{{ form_errors(form.profile_picture_file) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<button type="submit" class="form-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
33
templates/settings/security.html.twig
Normal file
33
templates/settings/security.html.twig
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% extends 'settings/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form %}
|
||||||
|
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.current_password, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.current_password, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.current_password) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.new_password, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.new_password, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.new_password) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form_label(form.new_password_confirmation, "" , { label_attr: { class: 'form-label' } }) }}
|
||||||
|
{{ form_widget(form.new_password_confirmation, { attr: { class: 'form-input' } }) }}
|
||||||
|
{{ form_errors(form.new_password_confirmation) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-wrapper__footer">
|
||||||
|
<button type="submit" class="form-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
<link rel="stylesheet" href="{{ asset('styles/vitrine.css') }}">
|
<link rel="stylesheet" href="{{ asset('styles/vitrine/index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block main %}
|
||||||
<main class="bulle-container">
|
<div class="bulle-container">
|
||||||
<section class="bulle bulle-all-col flex-column align-items-center">
|
<section class="bulle bulle-all-col flex-column align-items-center">
|
||||||
<h1 class="text-center">Bienvenue sur le serveur de Ploush</h1>
|
<h1 class="text-center">Bienvenue sur le serveur de Ploush</h1>
|
||||||
<p class="text-center">Le serveur est accessible a l'adresse :
|
<p class="text-center">Le serveur est accessible a l'adresse :
|
||||||
|
|
@ -33,5 +33,5 @@
|
||||||
<h4>Bienvenue sur le serveur de Ploush</h4>
|
<h4>Bienvenue sur le serveur de Ploush</h4>
|
||||||
<p>Celui-ci est accessible a l'adresse <span class="copy-link" data-controller="copy-link" data-action="click->copy-link#copy" data-copy-link-target="link" data-copy-text="ploush.top">ploush.top</span></p>
|
<p>Celui-ci est accessible a l'adresse <span class="copy-link" data-controller="copy-link" data-action="click->copy-link#copy" data-copy-link-target="link" data-copy-text="ploush.top">ploush.top</span></p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue