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:
1 parent
3579c9dfec
commit
94f89be605
19 files changed
+103
-48
No files matched your search
@@ -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())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in new issue
Block a user