chore: harden config, strengthen frontend typing, fix UTF-8 mojibake

Security config (3.1):
- config/session.ts: cookie.secure now follows app.inProduction instead of
  being hardcoded to false
- config/shield.ts: enable CSP with baseline directives compatible with
  Vite dev server and Inertia (self + @viteDevUrl/@viteHmrUrl)

Frontend typing (3.2):
- Replace 'any' props with concrete types (Computer, Component, ComponentType,
  ComputerState) across computers/*.vue and components/*.vue, using the
  existing inertia/pages/types/UITypes.ts definitions
- Fix UITypes.ts: export ComponentType/ComputerState, switch from interface
  to type (interfaces are never assignable to Inertia's ComponentProps index
  signature), drop the stale componentTypeId/componentStateId fields that
  didn't match the actual Lucid schema (real field is typeId, no component
  state exists)
- Add an asPageProp() boundary cast in the two controllers: Lucid's
  serialize() returns a loosely typed ModelObject that isn't structurally
  assignable to the concrete page prop shapes (and the inertia/** tree is
  excluded from the backend tsconfig, so real types can't be imported
  directly across that boundary)

UTF-8 mojibake fixes (3.3):
- Repair broken accented characters (├®/├á/├┤ -> é/à/ô, Ôé¼ -> €) left over
  from a bad encoding during the GWT->Inertia migration, across
  ComponentEditor.vue, ComputerComponentsTable.vue, SimpleGallery.vue,
  TableComponent.vue, isAdminMode.ts and the custom directives

Misc cleanup (3.5):
- computer.ts: replace console.warn with the app logger (includes
  computerId) when .cost is accessed without preloading components
This commit is contained in:
Kevin L'Hereec committed 2026-07-23 11:10:37 +02:00
1 parent 3579c9dfec
commit 94f89be605
19 files changed
+103 -48

No files matched your search

+23 -5
View File
@@ -5,16 +5,34 @@ import { updateComponentValidator } from '#validators/Components/update'
import Component from '#models/component'
import ComponentType from '#models/component_type'
/**
* Lucid's `serialize()` returns a loosely typed `ModelObject`
* (`{ [key: string]: any }`). It is not structurally assignable to the
* concrete prop shapes declared by the Vue pages (see
* `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript
* project (the `inertia/**` tree is excluded from the backend tsconfig).
* Since the controller fully controls which columns/relations are
* preloaded and serialized, this cast documents that the runtime shape
* matches what the page expects at this boundary.
*/
function asPageProp<T>(value: unknown): T {
return value as T
}
export default class ComponentsController {
async index({ inertia, auth }: HttpContext) {
const user = await auth.authenticate()
const components = await Component.query().preload('type').where('userId', user.id)
return inertia.render('components/index', { components: components.map((c) => c.serialize()) })
return inertia.render('components/index', {
components: asPageProp(components.map((c) => c.serialize())),
})
}
async create({ inertia }: HttpContext) {
const types = await ComponentType.all()
return inertia.render('components/create', { types: types.map((t) => t.serialize()) })
return inertia.render('components/create', {
types: asPageProp(types.map((t) => t.serialize())),
})
}
async store({ request, response, auth }: HttpContext) {
@@ -39,7 +57,7 @@ export default class ComponentsController {
.where('userId', user.id)
.andWhere('id', componentId)
.firstOrFail()
return inertia.render('components/show', { component: component.serialize() })
return inertia.render('components/show', { component: asPageProp(component.serialize()) })
}
async edit({ params, auth, inertia }: HttpContext) {
@@ -52,8 +70,8 @@ export default class ComponentsController {
.andWhere('id', componentId)
.firstOrFail()
return inertia.render('components/edit', {
component: component.serialize(),
types: types.map((t) => t.serialize()),
component: asPageProp(component.serialize()),
types: asPageProp(types.map((t) => t.serialize())),
})
}
+27 -4
View File
@@ -9,6 +9,20 @@ import { handlePictures } from '#services/picture_service'
import type { Image } from '#types/UItypes'
import { updateComputerValidator } from '#validators/Computers/update'
/**
* Lucid's `serialize()` returns a loosely typed `ModelObject`
* (`{ [key: string]: any }`). It is not structurally assignable to the
* concrete prop shapes declared by the Vue pages (see
* `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript
* project (the `inertia/**` tree is excluded from the backend tsconfig).
* Since the controller fully controls which columns/relations are
* preloaded and serialized, this cast documents that the runtime shape
* matches what the page expects at this boundary.
*/
function asPageProp<T>(value: unknown): T {
return value as T
}
export default class ComputersController {
async index({ auth, inertia }: HttpContext) {
const user = await auth.authenticate()
@@ -20,7 +34,9 @@ export default class ComputersController {
picturesQuery.where('order', 1)
})
return inertia.render('computers/index', { computers: computers.map((c) => c.serialize()) })
return inertia.render('computers/index', {
computers: asPageProp(computers.map((c) => c.serialize())),
})
}
async create({ auth, inertia }: HttpContext) {
@@ -30,7 +46,10 @@ export default class ComputersController {
.where('userId', user.id)
.whereNull('computerId')
.preload('type')
return inertia.render('computers/create', { components: components.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
return inertia.render('computers/create', {
components: asPageProp(components.map((c) => c.serialize())),
states: asPageProp(states.map((s) => s.serialize())),
})
}
async store({ auth, request, response }: HttpContext) {
@@ -61,7 +80,7 @@ export default class ComputersController {
.where('user_id', user.id)
.andWhere('id', computerId)
.firstOrFail()
return inertia.render('computers/show', { computer: computer.serialize() })
return inertia.render('computers/show', { computer: asPageProp(computer.serialize()) })
}
async edit({ params, inertia, auth }: HttpContext) {
@@ -87,7 +106,11 @@ export default class ComputersController {
'id',
computer.components.map((c) => c.id)
)
return inertia.render('computers/edit', { computer: computer.serialize(), availableComponents: availableComponents.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
return inertia.render('computers/edit', {
computer: asPageProp(computer.serialize()),
availableComponents: asPageProp(availableComponents.map((c) => c.serialize())),
states: asPageProp(states.map((s) => s.serialize())),
})
}
async update({ auth, params, request, response }: HttpContext) {
+5 -1
View File
@@ -1,6 +1,7 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column, computed, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import logger from '@adonisjs/core/services/logger'
import ComputerState from '#models/computer_state'
import ComputerPicture from '#models/computer_picture'
import Component from '#models/component'
@@ -52,7 +53,10 @@ export default class Computer extends BaseModel {
@computed()
get cost(): number {
if (!this.$preloaded.components) {
console.warn('Components not preloaded, cost may be incorrect')
logger.warn(
{ computerId: this.id },
'Computer.cost accessed without preloading "components"; returning 0'
)
return 0
}
return this.components.reduce((total, component) => total + Number(component.price), 0)
+2 -1
View File
@@ -1,4 +1,5 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
@@ -42,7 +43,7 @@ const sessionConfig = defineConfig({
/**
* Send cookies only over HTTPS in production.
*/
secure: false,
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
+9 -2
View File
@@ -9,12 +9,19 @@ const shieldConfig = defineConfig({
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
enabled: true,
/**
* Per-resource CSP directives.
*/
directives: {},
directives: {
defaultSrc: [`'self'`, '@viteDevUrl'],
connectSrc: [`'self'`, '@viteHmrUrl'],
scriptSrc: [`'self'`, '@viteDevUrl'],
styleSrc: [`'self'`, '@viteDevUrl', `'unsafe-inline'`],
imgSrc: [`'self'`, 'data:'],
fontSrc: [`'self'`, 'data:'],
},
/**
* Report violations without blocking resources.
+2 -2
View File
@@ -1,9 +1,9 @@
import { ref, watch } from 'vue'
// Cr├®er une r├®f├®rence pour isAdminActive
// Créer une référence pour isAdminActive
const isAdminActive = ref(JSON.parse(localStorage.getItem('isAdminActive') || 'false'))
// Surveiller les changements de isAdminActive et mettre à jour localStorage
// Surveiller les changements de isAdminActive et mettre à jour localStorage
watch(isAdminActive, (newValue) => {
localStorage.setItem('isAdminActive', JSON.stringify(newValue))
})
+5 -5
View File
@@ -1,7 +1,7 @@
import { createApp, DirectiveBinding, h, ObjectDirective } from 'vue'
import DeleteConfirmationModal from '~/pages/widgets/DeleteConfirmationModal.vue'
// Interface pour ├®tendre HTMLElement avec _handleClick
// Interface pour étendre HTMLElement avec _handleClick
interface HTMLElementWithHandleClick extends HTMLElement {
_handleClick?: (event: Event) => void
}
@@ -9,8 +9,8 @@ interface HTMLElementWithHandleClick extends HTMLElement {
const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
mounted(el: HTMLElementWithHandleClick, binding: DirectiveBinding) {
const handleClick = (event: Event) => {
event.stopPropagation() // Emp├¬cher la propagation de l'├®v├®nement
event.preventDefault() // Emp├¬cher l'action par d├®faut
event.stopPropagation() // Empêcher la propagation de l'événement
event.preventDefault() // Empêcher l'action par défaut
const modalApp = createApp({
data() {
@@ -20,7 +20,7 @@ const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
confirm() {
this.show = false
document.body.removeChild(modalContainer)
binding.value() // Ex├®cuter la fonction de suppression
binding.value() // Exécuter la fonction de suppression
},
cancel() {
this.show = false
@@ -42,7 +42,7 @@ const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
}
el.addEventListener('click', handleClick)
el._handleClick = handleClick // Stocker la r├®f├®rence pour la suppression ult├®rieure
el._handleClick = handleClick // Stocker la référence pour la suppression ultérieure
},
unmounted(el: HTMLElementWithHandleClick) {
if (el._handleClick) {
+3 -3
View File
@@ -6,7 +6,7 @@ const formatPrice = (value: string): string => {
return Number.isNaN(numberValue) ? '' : numberValue.toFixed(2).replace('.', ',')
}
// Interface pour ├®tendre HTMLInputElement avec _handleBlur
// Interface pour étendre HTMLInputElement avec _handleBlur
interface HTMLInputElementWithHandleBlur extends HTMLInputElement {
_handleBlur?: () => void
}
@@ -15,10 +15,10 @@ const priceDirective: ObjectDirective<HTMLInputElementWithHandleBlur> = {
mounted(el: HTMLInputElementWithHandleBlur) {
const handleBlur = () => {
el.value = formatPrice(el.value)
el.dispatchEvent(new Event('input')) // Pour mettre à jour le v-model
el.dispatchEvent(new Event('input')) // Pour mettre à jour le v-model
}
el.addEventListener('blur', handleBlur)
el._handleBlur = handleBlur // Stocker la r├®f├®rence pour la suppression ult├®rieure
el._handleBlur = handleBlur // Stocker la référence pour la suppression ultérieure
},
unmounted(el: HTMLInputElementWithHandleBlur) {
if (el._handleBlur) {
+3 -3
View File
@@ -33,7 +33,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
const vnode = createVNode(ValidationError, { message: errorMessage })
render(vnode, container)
// Force reflow pour d├®clencher la transition
// Force reflow pour déclencher la transition
void container.offsetWidth
container.classList.remove('w-0', 'opacity-0')
container.classList.add('w-full', 'opacity-100')
@@ -77,7 +77,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
const vnode = createVNode(ValidationError, { message: errorMessage })
render(vnode, newContainer)
// Force reflow pour d├®clencher la transition
// Force reflow pour déclencher la transition
void newContainer.offsetWidth
newContainer.classList.remove('w-0', 'opacity-0')
newContainer.classList.add('w-100', 'opacity-100')
@@ -92,7 +92,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
container.classList.remove('transition-all', 'duration-2500', 'ease-in-out') // Supprimez les classes de transition pour la disparition
container.classList.add('w-0', 'opacity-0')
// Supprimez le conteneur imm├®diatement sans d├®lai
// Supprimez le conteneur immédiatement sans délai
render(null, container)
container.remove()
;(el as any)._validationErrorContainer = null
+2 -1
View File
@@ -3,8 +3,9 @@ import { reactive } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { useForm } from '@inertiajs/vue3'
import CustomSelect from '~/pages/widgets/CustomSelect.vue'
import type { ComponentType } from '~/pages/types/UITypes'
defineProps<{ types: any[]; errors?: Record<string, string> }>()
defineProps<{ types: ComponentType[]; errors?: Record<string, string> }>()
const form = reactive({
name: '',
price: 0,
+2 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link } from '@adonisjs/inertia/vue'
import type { Component } from '~/pages/types/UITypes'
const props = defineProps<{ component: any }>()
const props = defineProps<{ component: Component }>()
</script>
<template>
+5 -4
View File
@@ -2,11 +2,12 @@
import { reactive } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { router, usePage } from '@inertiajs/vue3'
import type { Component, Computer, ComputerState } from '~/pages/types/UITypes'
const { props } = usePage<{
computer: any
availableComponents: any[]
states: any[]
computer: Computer
availableComponents: Component[]
states: ComputerState[]
errors?: Record<string, string>
}>()
const { computer, states } = props
@@ -19,7 +20,7 @@ const form = reactive({
cpuScore: computer.cpuScore,
globalScore: computer.globalScore,
stateId: computer.state.id,
componentsId: computer.components.map((c: any) => c.id),
componentsId: computer.components.map((c) => c.id),
})
function deleteComputer(id: number) {
+2 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link } from '@adonisjs/inertia/vue'
import type { Computer } from '~/pages/types/UITypes'
defineProps<{ computers: any[] }>()
defineProps<{ computers: Computer[] }>()
</script>
<template>
+2 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link } from '@adonisjs/inertia/vue'
import type { Computer } from '~/pages/types/UITypes'
const { computer } = defineProps<{ computer: any }>()
const { computer } = defineProps<{ computer: Computer }>()
</script>
<template>
+2 -5
View File
@@ -1,10 +1,10 @@
interface ComponentType {
export type ComponentType = {
id: number
name: string
unique: boolean
}
interface ComputerState {
export type ComputerState = {
id: number
name: string
}
@@ -26,10 +26,7 @@ export type Component = {
id: number
name: string
price: string
componentTypeId: number
computerId: number | null
userId: number
componentStateId: number
purchaseDate: string
createdAt: string
updatedAt: string
+2 -2
View File
@@ -30,7 +30,7 @@ const componentTypeOrder = [
'Other',
]
// Fusionner les composants disponibles et initialement s├®lectionn├®s
// Fusionner les composants disponibles et initialement sélectionnés
const allComponents = computed(() => {
const mergedComponents = [...props.availableComponents]
if (props.initialSelectedComponents) {
@@ -134,7 +134,7 @@ function updateComponents(event: Event, type: string) {
</select>
</div>
<div v-else>
<!-- Utilisation de cases à cocher pour les composants non uniques avec grille responsive -->
<!-- Utilisation de cases à cocher pour les composants non uniques avec grille responsive -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="component in components" :key="component.id" class="flex items-center mt-1">
<input
@@ -46,7 +46,7 @@ const componentTypeOrder = [
'Other',
]
// Trier les composants selon l'ordre sp├®cifi├®
// Trier les composants selon l'ordre spécifié
const sortedComponents = computed(() => {
return components.slice().sort((a, b) => {
return componentTypeOrder.indexOf(a.type.name) - componentTypeOrder.indexOf(b.type.name)
@@ -67,7 +67,7 @@ function getIconPath(typeName: string): string {
'WiFi-Card': wiFiCard,
'Case': pcCase,
}
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
return typeToIconMap[typeName] || defaultIcon // Utilisez une icône par défaut si nécessaire
}
</script>
@@ -106,7 +106,7 @@ function getIconPath(typeName: string): string {
</td>
<td v-if="isAdminActive" class="px-4 py-2 border-b border-gray-400 text-right">
Ôé¼{{ component.price }}
€{{ component.price }}
</td>
<td class="px-4 py-2 border-b border-gray-400 flex justify-center items-center">
<Link
@@ -125,7 +125,7 @@ function getIconPath(typeName: string): string {
<tr class="bg-gray-800 text-white">
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap" colspan="3">Total</td>
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap text-right">
Ôé¼{{ cost.toFixed(2) }}
€{{ cost.toFixed(2) }}
</td>
</tr>
</tfoot>
+1 -1
View File
@@ -165,7 +165,7 @@ function handleDragEnter(index: number) {
const sourceArray =
dragIndex.value < serverImages.value.length ? serverImages.value : newImages.value
const draggedItem = sourceArray.splice(dragIndex.value, 1)[0]
sourceArray.splice(index, 0, draggedItem as any) // Utilisation de 'as any' pour r├®soudre le probl├¿me de typage
sourceArray.splice(index, 0, draggedItem as any) // Utilisation de 'as any' pour résoudre le problème de typage
dragIndex.value = index
}
}
+2 -2
View File
@@ -38,7 +38,7 @@ function getIconPath(typeName: string): string {
'WiFi-Card': wiFiCard,
'Case': pcCase,
}
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
return typeToIconMap[typeName] || defaultIcon // Utilisez une icône par défaut si nécessaire
}
function goToDetail(id: number) {
@@ -105,7 +105,7 @@ function deleteComponent(id: number) {
{{ component.name }}
</td>
<td v-if="isAdminActive" class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
Ôé¼{{ component.price }}
€{{ component.price }}
</td>
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
{{ component.purchaseDate }}