Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
8 changed files with 177 additions and 2 deletions

View File

@@ -21,3 +21,6 @@ mcp:
store: file store: file
directory: '%kernel.project_dir%/var/mcp-sessions' directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600 ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.3.32' app.version: '0.3.34'

View File

@@ -393,7 +393,21 @@
"title": "Mon profil", "title": "Mon profil",
"changeAvatar": "Changer l'avatar", "changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar", "removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar" "cropAvatar": "Recadrer l'avatar",
"apiToken": {
"title": "Token API MCP",
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
"label": "Token",
"empty": "Aucun token généré pour le moment.",
"generate": "Générer un token",
"regenerate": "Régénérer",
"copy": "Copier",
"copied": "Token copié dans le presse-papiers.",
"copyFailed": "Impossible de copier le token.",
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
"confirmTitle": "Régénérer le token MCP ?",
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
}
}, },
"bookstack": { "bookstack": {
"settings": { "settings": {

View File

@@ -37,6 +37,56 @@
</div> </div>
</div> </div>
<!-- API Token MCP (interne uniquement) -->
<div
v-if="!isClientOnly"
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
>
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
<div v-if="auth.user?.apiToken">
<MalioInputPassword
:model-value="auth.user.apiToken"
:label="$t('profile.apiToken.label')"
readonly
@update:model-value="() => {}"
/>
<div class="mt-3 flex flex-wrap gap-3">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
icon-name="mdi:content-copy"
icon-position="left"
:label="$t('profile.apiToken.copy')"
@click="onCopy"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
icon-name="mdi:refresh"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="showConfirm = true"
/>
</div>
</div>
<div v-else>
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
<MalioButton
variant="primary"
button-class="w-auto px-4"
icon-name="mdi:key-plus"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.generate')"
@click="onRegenerate"
/>
</div>
</div>
<!-- Crop modal --> <!-- Crop modal -->
<AvatarCropper <AvatarCropper
v-if="selectedFile" v-if="selectedFile"
@@ -44,14 +94,45 @@
@crop="onCrop" @crop="onCrop"
@cancel="selectedFile = null" @cancel="selectedFile = null"
/> />
<!-- Confirm regenerate modal -->
<Teleport v-if="showConfirm" to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('profile.apiToken.confirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="showConfirm = false"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="onRegenerate"
/>
</div>
</div>
</div>
</Teleport>
</div> </div>
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const { t } = useI18n()
const isClientOnly = computed(() => const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN') auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
@@ -61,9 +142,12 @@ definePageMeta({
layout: false, layout: false,
}) })
const { upload, remove } = useAvatarService() const { upload, remove } = useAvatarService()
const { regenerate } = useApiTokenService()
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)
const removing = ref(false) const removing = ref(false)
const regenerating = ref(false)
const showConfirm = ref(false)
function onFileSelect(event: Event) { function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -97,4 +181,28 @@ async function onRemove() {
removing.value = false removing.value = false
} }
} }
async function onCopy() {
if (!auth.user?.apiToken) return
try {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') })
} catch {
toast.error({ message: t('profile.apiToken.copyFailed') })
}
}
async function onRegenerate() {
regenerating.value = true
try {
const newToken = await regenerate()
if (auth.user) {
auth.user.apiToken = newToken
}
showConfirm.value = false
toast.success({ message: t('profile.apiToken.regenerated') })
} finally {
regenerating.value = false
}
}
</script> </script>

View File

@@ -0,0 +1,12 @@
export function useApiTokenService() {
const api = useApi()
async function regenerate(): Promise<string> {
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
toast: false,
})
return data.apiToken
}
return { regenerate }
}

View File

@@ -8,6 +8,7 @@ export type UserData = {
client?: { id: number; name: string } | null client?: { id: number; name: string } | null
allowedProjects?: Project[] allowedProjects?: Project[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null
} }
export type UserWrite = { export type UserWrite = {

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function bin2hex;
use function random_bytes;
class RegenerateApiTokenController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$token = bin2hex(random_bytes(32));
$user->setApiToken($token);
$this->entityManager->flush();
return new JsonResponse(['apiToken' => $token]);
}
}

View File

@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?DateTimeImmutable $createdAt = null; private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)] #[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
private ?string $apiToken = null; private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]