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
This commit is contained in:
Matthieu
2026-05-13 14:58:19 +02:00
parent b25be8fd6a
commit feaa9f1875
6 changed files with 173 additions and 1 deletions

View File

@@ -393,7 +393,21 @@
"title": "Mon profil",
"changeAvatar": "Changer 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": {
"settings": {

View File

@@ -37,6 +37,56 @@
</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 -->
<AvatarCropper
v-if="selectedFile"
@@ -44,14 +94,45 @@
@crop="onCrop"
@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>
</NuxtLayout>
</template>
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
const auth = useAuthStore()
const toast = useToast()
const { t } = useI18n()
const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
@@ -61,9 +142,12 @@ definePageMeta({
layout: false,
})
const { upload, remove } = useAvatarService()
const { regenerate } = useApiTokenService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
const regenerating = ref(false)
const showConfirm = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
@@ -97,4 +181,28 @@ async function onRemove() {
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>

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
allowedProjects?: Project[]
avatarUrl?: string | null
apiToken?: string | null
}
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;
#[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)]