Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e |
13
CLAUDE.md
13
CLAUDE.md
@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||
|
||||
### Composants UI
|
||||
|
||||
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||
|
||||
### MCP Server
|
||||
|
||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -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.31'
|
||||
app.version: '0.3.34'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||
)
|
||||
|
||||
// ── Filtered data (client-side project filter) ──
|
||||
// ── Filtered data (client-side project + user filter) ──
|
||||
|
||||
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||
|
||||
const tasks = computed(() => {
|
||||
if (!selectedProjectId.value) return allTasks.value
|
||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
||||
let result = allTasks.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedUserId.value) {
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedUserId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const timeEntries = computed(() => {
|
||||
@@ -173,8 +184,8 @@ const totalHoursThisWeek = computed(() =>
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t =>
|
||||
t.assignee?.id === auth.user?.id
|
||||
|| t.collaborators?.some(c => c.id === auth.user?.id)
|
||||
t.assignee?.id === effectiveUserId.value
|
||||
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
@@ -258,6 +259,12 @@ const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'kanban') {
|
||||
selectedStatusId.value = null
|
||||
}
|
||||
})
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
let result = entries.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||
}
|
||||
return result
|
||||
})
|
||||
const filteredEntries = computed(() => entries.value)
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() - 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() - 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() + 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() + 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
if (viewMode.value === 'day') {
|
||||
end.setDate(end.getDate() + 1)
|
||||
} else if (viewMode.value === 'list') {
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
} else {
|
||||
end.setDate(end.getDate() + 7)
|
||||
}
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
project: selectedProjectId.value ?? undefined,
|
||||
tag: selectedTagId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
// keep current date
|
||||
} else if (viewMode.value === 'list') {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
startDate.value = getMonday(startDate.value)
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedUserId, () => {
|
||||
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
||||
after: string
|
||||
before: string
|
||||
user?: number
|
||||
types?: number[]
|
||||
project?: number
|
||||
tag?: number
|
||||
}): Promise<TimeEntry[]> {
|
||||
const query: Record<string, unknown> = {
|
||||
'startedAt[after]': params.after,
|
||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
||||
if (params.user) {
|
||||
query.user = `/api/users/${params.user}`
|
||||
}
|
||||
if (params.project) {
|
||||
query.project = `/api/projects/${params.project}`
|
||||
}
|
||||
if (params.tag) {
|
||||
query['tags[]'] = `/api/task_tags/${params.tag}`
|
||||
}
|
||||
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
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