Compare commits
5 Commits
v0.3.32
...
feat/proje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4184cadfe4 | ||
|
|
88a4916662 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 |
@@ -21,3 +21,6 @@ mcp:
|
||||
store: file
|
||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||
ttl: 3600
|
||||
discovery:
|
||||
scan_dirs: ['src']
|
||||
exclude_dirs: ['DataFixtures']
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.32'
|
||||
app.version: '0.3.33'
|
||||
|
||||
225
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
225
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Workflows de statuts par projet (Kanban custom)
|
||||
|
||||
**Date** : 2026-05-19
|
||||
**Branche** : `feat/project-workflows`
|
||||
**Statut** : design validé, en attente de plan d'implémentation
|
||||
|
||||
## Reprise sur un autre poste
|
||||
|
||||
> **Pour le prochain Claude qui ouvre cette branche :**
|
||||
>
|
||||
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
|
||||
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
|
||||
> 3. **Aucun code applicatif n'a encore été écrit.**
|
||||
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
|
||||
> 5. **Avant de lancer le plan, vérifier avec Matthieu** s'il a relu le spec et veut des ajustements sur :
|
||||
> - Hors scope (§8) — rien d'oublié pour la V1 ?
|
||||
> - Fallback `in_progress` pour statuts non-mappables (§3, M2) — OK ou échec migration ?
|
||||
> - Suppression d'AdminStatusTab (§5) — OK de tout fusionner dans l'onglet Workflows ?
|
||||
> - Ordre des étapes de livraison (§10) — OK ou réordonner ?
|
||||
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
|
||||
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
|
||||
|
||||
## 1. Contexte et besoin
|
||||
|
||||
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
|
||||
|
||||
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
|
||||
|
||||
## 2. Modèle de données
|
||||
|
||||
### Nouvelle entité : `Workflow`
|
||||
|
||||
```
|
||||
Workflow
|
||||
- id int, PK
|
||||
- name string(255), unique
|
||||
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
|
||||
- position int (pour l'ordre dans l'admin)
|
||||
- statuses OneToMany → TaskStatus (inverse côté Workflow)
|
||||
```
|
||||
|
||||
### Modifications : `TaskStatus`
|
||||
|
||||
```
|
||||
TaskStatus
|
||||
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
|
||||
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
|
||||
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
|
||||
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
|
||||
```
|
||||
|
||||
### Modifications : `Project`
|
||||
|
||||
```
|
||||
Project
|
||||
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
|
||||
```
|
||||
|
||||
### Choix de design
|
||||
|
||||
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
|
||||
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
|
||||
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
|
||||
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
|
||||
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
|
||||
|
||||
## 3. Migrations BDD
|
||||
|
||||
Trois migrations Doctrine successives :
|
||||
|
||||
**M1 — `create_workflow_table`**
|
||||
- Crée la table `workflow` (id, name, is_default, position)
|
||||
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
|
||||
|
||||
**M2 — `add_workflow_to_task_status`**
|
||||
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
|
||||
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
|
||||
- Backfill catégories :
|
||||
- "À faire" → `todo`
|
||||
- "En cours" → `in_progress`
|
||||
- "Bloqué" → `blocked`
|
||||
- "En attente de validation" → `review`
|
||||
- "Terminé" → `done`
|
||||
- Tout autre statut existant non-mappable → `in_progress` (fallback neutre) + log warning
|
||||
- Passe les 2 colonnes en `NOT NULL`
|
||||
|
||||
**M3 — `add_workflow_to_project`**
|
||||
- Ajoute `project.workflow_id` nullable
|
||||
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
|
||||
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
|
||||
|
||||
## 4. Backend (Symfony / API Platform)
|
||||
|
||||
### Entités
|
||||
|
||||
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
|
||||
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
|
||||
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
|
||||
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
|
||||
|
||||
### Sérialisation
|
||||
|
||||
- Groupe `workflow:read` pour l'API admin
|
||||
- `task_status:read` ajoute `workflow` et `category`
|
||||
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
|
||||
|
||||
### Endpoint dédié au switch
|
||||
|
||||
```
|
||||
POST /api/projects/{id}/switch-workflow
|
||||
Body: {
|
||||
workflowId: int,
|
||||
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
|
||||
}
|
||||
Security: ROLE_ADMIN
|
||||
```
|
||||
|
||||
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
|
||||
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
|
||||
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
|
||||
3. Transaction unique :
|
||||
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
|
||||
- `UPDATE project SET workflow_id = <new>`
|
||||
4. Retourne `{ project, migratedTaskCount }`
|
||||
|
||||
### Validation cross-entity
|
||||
|
||||
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
|
||||
|
||||
### Suppression d'un Workflow
|
||||
|
||||
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
|
||||
|
||||
## 5. Frontend (Nuxt / Vue)
|
||||
|
||||
### Nouveaux fichiers
|
||||
|
||||
- `frontend/services/workflows.ts` — service API CRUD
|
||||
- `frontend/services/dto/workflow.ts` — type TS
|
||||
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
|
||||
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
|
||||
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
|
||||
|
||||
### Modifications
|
||||
|
||||
- `frontend/components/admin/AdminStatusTab.vue` :
|
||||
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
|
||||
- `frontend/components/project/ProjectDrawer.vue` :
|
||||
- Affiche le workflow actuel
|
||||
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
|
||||
- `frontend/pages/projects/[id]/index.vue` :
|
||||
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
|
||||
- Le kanban a les colonnes du workflow du projet
|
||||
- `frontend/pages/projects/[id]/archives.vue` :
|
||||
- Filtre statut limité au workflow du projet
|
||||
- `frontend/pages/my-tasks.vue` :
|
||||
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
|
||||
- Chaque card affiche le statut spécifique en badge
|
||||
- Vue liste : pas de changement
|
||||
- `frontend/components/task/TaskModal.vue` :
|
||||
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
|
||||
- `frontend/components/task/TaskBulkActions.vue` :
|
||||
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
|
||||
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
|
||||
|
||||
### `ProjectWorkflowSwitchModal.vue` — détails UX
|
||||
|
||||
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
|
||||
- Étape 2 (après sélection) : tableau de mapping
|
||||
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
|
||||
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
|
||||
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
|
||||
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
|
||||
- Footer :
|
||||
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
|
||||
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
|
||||
|
||||
## 6. MCP
|
||||
|
||||
| Tool | Changement |
|
||||
|---|---|
|
||||
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
|
||||
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
|
||||
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
|
||||
| `switch-project-workflow` (V2 — non bloquant) | Permet de piloter le switch via MCP. Reporté à une itération ultérieure si pas critique. |
|
||||
|
||||
## 7. Permissions
|
||||
|
||||
| Action | Rôle requis |
|
||||
|---|---|
|
||||
| Lire les workflows et leurs statuts | `ROLE_USER` |
|
||||
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
|
||||
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
|
||||
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
|
||||
|
||||
## 8. Hors scope (YAGNI explicites)
|
||||
|
||||
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
|
||||
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
|
||||
- **Versioning des workflows** (historique des modifs) — pas demandé
|
||||
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
|
||||
- **MCP `switch-project-workflow`** — peut être ajouté en V2 si le besoin se manifeste
|
||||
|
||||
## 9. Risques et limites
|
||||
|
||||
- **Migration M2 (backfill catégories)** : si un déploiement intermédiaire a créé des statuts non prévus, ils tombent sur le fallback `in_progress`. Vérifier l'état de la prod avant migration et ajuster le SQL si besoin.
|
||||
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
|
||||
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
|
||||
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
|
||||
|
||||
## 10. Étapes de livraison suggérées
|
||||
|
||||
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
|
||||
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
|
||||
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
|
||||
4. Service frontend `workflows` + types DTO
|
||||
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
|
||||
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
|
||||
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
|
||||
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
|
||||
9. Adaptation `TaskBulkActions` et autres écrans transverses
|
||||
10. MCP : modification `list-statuses` + nouveau `list-workflows` + mise à jour des descriptions
|
||||
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch
|
||||
|
||||
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
frontend/services/api-token.ts
Normal file
12
frontend/services/api-token.ts
Normal 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 }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type UserData = {
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
|
||||
36
src/Controller/RegenerateApiTokenController.php
Normal file
36
src/Controller/RegenerateApiTokenController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user