Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e | ||
|
|
258c6e9c17 | ||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
|
|
f1fd80d9ac | ||
|
|
24e3e8e989 |
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`
|
- 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
|
- 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
|
### MCP Server
|
||||||
|
|
||||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
- 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`
|
- 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
|
- 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)
|
- 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
|
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']
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.29'
|
app.version: '0.3.34'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/lesstime.conf:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
@@ -66,14 +66,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
<MalioInputRichText
|
||||||
{{ $t('clientTicket.description') }}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editForm.description"
|
v-model="editForm.description"
|
||||||
rows="5"
|
:label="$t('clientTicket.description')"
|
||||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
min-height="180px"
|
||||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +125,13 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
group-class="mt-1"
|
||||||
|
/>
|
||||||
|
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL (if bug) -->
|
<!-- URL (if bug) -->
|
||||||
|
|||||||
@@ -116,7 +116,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-description="{ item }">
|
<template #cell-description="{ item }">
|
||||||
{{ item.description ?? '—' }}
|
{{ stripRichText(item.description) || '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ item }">
|
<template #actions="{ item }">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number
|
projectId: number
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||||
@blur="touched.title = true"
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="3"
|
min-height="120px"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
|
|||||||
@@ -196,33 +196,13 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<div class="mb-1 flex items-center justify-between">
|
<MalioInputRichText
|
||||||
<label class="text-sm font-medium text-slate-700">Description</label>
|
|
||||||
<button
|
|
||||||
v-if="form.description"
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
|
||||||
@click="showMarkdownPreview = true"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:eye" class="size-3.5" />
|
|
||||||
Aperçu MD
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<MalioInputTextArea
|
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:size="5"
|
label="Description"
|
||||||
resize="vertical"
|
min-height="180px"
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MarkdownPreviewModal
|
|
||||||
v-model="showMarkdownPreview"
|
|
||||||
:content="form.description"
|
|
||||||
title="Aperçu de la description"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
<!-- Documents -->
|
||||||
<TaskDocumentUpload
|
<TaskDocumentUpload
|
||||||
v-if="isEditing && task && isAdmin"
|
v-if="isEditing && task && isAdmin"
|
||||||
@@ -558,7 +538,7 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value || showMarkdownPreview.value) return
|
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,7 +546,6 @@ const isEditing = computed(() => !!props.task)
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const confirmDeleteOpen = ref(false)
|
const confirmDeleteOpen = ref(false)
|
||||||
const activeTab = ref<'details' | 'planning'>('details')
|
const activeTab = ref<'details' | 'planning'>('details')
|
||||||
const showMarkdownPreview = ref(false)
|
|
||||||
|
|
||||||
const giteaUrl = ref('')
|
const giteaUrl = ref('')
|
||||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||||
|
|||||||
@@ -11,14 +11,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<MalioInputRichText
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
v-model="form.description"
|
||||||
<textarea
|
label="Description"
|
||||||
v-model="form.description"
|
min-height="120px"
|
||||||
rows="3"
|
/>
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="overflow-y-auto px-6 py-4">
|
<div class="overflow-y-auto px-6 py-4">
|
||||||
<div
|
<div
|
||||||
v-if="content"
|
v-if="content"
|
||||||
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100"
|
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||||
v-html="renderedHtml"
|
v-html="renderedHtml"
|
||||||
/>
|
/>
|
||||||
<p v-else class="text-sm italic text-slate-400">
|
<p v-else class="text-sm italic text-slate-400">
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['markdown-it-task-lists'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
882
frontend/package-lock.json
generated
882
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.2.3",
|
"@malio/layer-ui": "^1.4.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
|||||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
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(() => {
|
const tasks = computed(() => {
|
||||||
if (!selectedProjectId.value) return allTasks.value
|
let result = allTasks.value
|
||||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.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(() => {
|
const timeEntries = computed(() => {
|
||||||
@@ -173,8 +184,8 @@ const totalHoursThisWeek = computed(() =>
|
|||||||
|
|
||||||
const myTasks = computed(() =>
|
const myTasks = computed(() =>
|
||||||
tasks.value.filter(t =>
|
tasks.value.filter(t =>
|
||||||
t.assignee?.id === auth.user?.id
|
t.assignee?.id === effectiveUserId.value
|
||||||
|| t.collaborators?.some(c => c.id === auth.user?.id)
|
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,10 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:label="$t('clientTicket.description')"
|
:label="$t('clientTicket.description')"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
min-resize-width="100%"
|
|
||||||
max-resize-width="100%"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -84,7 +84,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -258,6 +259,12 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const selectedPriorityId = ref<number | null>(null)
|
const selectedPriorityId = ref<number | null>(null)
|
||||||
const selectedEffortId = ref<number | null>(null)
|
const selectedEffortId = ref<number | null>(null)
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
watch(viewMode, (mode) => {
|
||||||
|
if (mode === 'kanban') {
|
||||||
|
selectedStatusId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="User"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
|||||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => entries.value)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
const date = new Date(d)
|
const date = new Date(d)
|
||||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
|||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
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()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
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()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
|||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
const end = new Date(startDate.value)
|
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({
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
after: startDate.value.toISOString(),
|
after: startDate.value.toISOString(),
|
||||||
before: end.toISOString(),
|
before: end.toISOString(),
|
||||||
user: selectedUserId.value ?? undefined,
|
user: selectedUserId.value ?? undefined,
|
||||||
|
project: selectedProjectId.value ?? undefined,
|
||||||
|
tag: selectedTagId.value ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(viewMode, () => {
|
watch(viewMode, () => {
|
||||||
selectedDateFilter.value = null
|
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()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedUserId, () => {
|
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||||
loadEntries()
|
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
|
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 = {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
|||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
user?: number
|
||||||
types?: number[]
|
project?: number
|
||||||
|
tag?: number
|
||||||
}): Promise<TimeEntry[]> {
|
}): Promise<TimeEntry[]> {
|
||||||
const query: Record<string, unknown> = {
|
const query: Record<string, unknown> = {
|
||||||
'startedAt[after]': params.after,
|
'startedAt[after]': params.after,
|
||||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
|||||||
if (params.user) {
|
if (params.user) {
|
||||||
query.user = `/api/users/${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)
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
|
|||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripRichText(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'|'/gi, '\'')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|||||||
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;
|
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)]
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
|
|||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
use const ENT_HTML5;
|
||||||
|
use const ENT_QUOTES;
|
||||||
|
|
||||||
final class CalDavService
|
final class CalDavService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -199,7 +202,7 @@ final class CalDavService
|
|||||||
$project = $task->getProject();
|
$project = $task->getProject();
|
||||||
$projectCode = null !== $project ? $project->getCode() : '';
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||||
|
|
||||||
$vcalendar = new VCalendar();
|
$vcalendar = new VCalendar();
|
||||||
$vcalendar->add('VEVENT', [
|
$vcalendar->add('VEVENT', [
|
||||||
@@ -225,7 +228,7 @@ final class CalDavService
|
|||||||
$project = $task->getProject();
|
$project = $task->getProject();
|
||||||
$projectCode = null !== $project ? $project->getCode() : '';
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||||
|
|
||||||
$vcalendar = new VCalendar();
|
$vcalendar = new VCalendar();
|
||||||
$vcalendar->add('VTODO', [
|
$vcalendar->add('VTODO', [
|
||||||
@@ -337,6 +340,18 @@ final class CalDavService
|
|||||||
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function descriptionToPlainText(?string $value): string
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripped = strip_tags($value);
|
||||||
|
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private function getDayMap(): array
|
private function getDayMap(): array
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user