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`
|
- 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.31'
|
app.version: '0.3.34'
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user