Compare commits

...

5 Commits

Author SHA1 Message Date
Julien Calixte
0099e51f94 fix(transaction): initialize CurrencyInput with a real currency code
vue-currency-input required a currency code for Intl.NumberFormat;
passing undefined threw inside the mount watcher, leaving the input
without listeners so amount stayed null on submit.

Use CurrencyDisplay.hidden to keep the symbol out of the field since
the currency selector is rendered separately.
2026-06-02 21:24:39 +02:00
Julien Calixte
901f7f4c02 fix(sync): send credentials on cross-origin couchdb requests
PouchDB 8+ ignores the legacy ajax config, so withCredentials had no
effect and the AuthSession cookie was stripped from cross-origin sync
requests. Switch to a fetch wrapper that sets credentials: 'include'
so replication authenticates and pushes local docs to the remote.
2026-06-02 21:23:47 +02:00
Julien Calixte
7c3d34d02e refactor(forms): use arktype schemas for form validation
Replaces imperative validate() blocks in TransactionCreate, signup,
and AccountNew with schema-driven validation. Drops the dead
"(!currency && !date && payFor.length === 0)" branch in
TransactionCreate that was hiding behind the generic error.
2026-06-01 22:17:56 +02:00
Julien Calixte
0fc7118fd4 feat(validation): add arktype form schemas and i18n keys
Introduces field-specific validation messages so toasts name the
exact field and rule that failed instead of a catch-all "tous les
champs sont requis".
2026-06-01 22:17:50 +02:00
Julien Calixte
d61f391203 chore: add arktype dependency 2026-06-01 22:17:44 +02:00
13 changed files with 186 additions and 58 deletions

View File

@@ -20,6 +20,7 @@
"dependencies": { "dependencies": {
"@tabler/icons-webfont": "^3.30.0", "@tabler/icons-webfont": "^3.30.0",
"@vueuse/core": "^12.0.0", "@vueuse/core": "^12.0.0",
"arktype": "^2.2.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"events": "^3.3.0", "events": "^3.3.0",

31
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.8.2(typescript@5.9.3) version: 12.8.2(typescript@5.9.3)
arktype:
specifier: ^2.2.0
version: 2.2.0
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.16.1 version: 1.16.1
@@ -153,6 +156,12 @@ packages:
peerDependencies: peerDependencies:
ajv: '>=8' ajv: '>=8'
'@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
'@ark/util@0.56.0':
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -2001,6 +2010,12 @@ packages:
argsarray@0.0.1: argsarray@0.0.1:
resolution: {integrity: sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==} resolution: {integrity: sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==}
arkregex@0.0.5:
resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==}
arktype@2.2.0:
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4718,6 +4733,12 @@ snapshots:
jsonpointer: 5.0.1 jsonpointer: 5.0.1
leven: 3.1.0 leven: 3.1.0
'@ark/schema@0.56.0':
dependencies:
'@ark/util': 0.56.0
'@ark/util@0.56.0': {}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -6512,6 +6533,16 @@ snapshots:
argsarray@0.0.1: {} argsarray@0.0.1: {}
arkregex@0.0.5:
dependencies:
'@ark/util': 0.56.0
arktype@2.2.0:
dependencies:
'@ark/schema': 0.56.0
'@ark/util': 0.56.0
arkregex: 0.0.5
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue' import { watch } from 'vue'
import { useCurrencyInput } from 'vue-currency-input' import { useCurrencyInput, CurrencyDisplay } from 'vue-currency-input'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,7 +23,8 @@ const props = withDefaults(
defineEmits<{ 'update:modelValue': [value: number | null] }>() defineEmits<{ 'update:modelValue': [value: number | null] }>()
const { inputRef, setValue } = useCurrencyInput({ const { inputRef, setValue } = useCurrencyInput({
currency: undefined as unknown as string, currency: 'USD',
currencyDisplay: CurrencyDisplay.hidden,
locale: props.locale, locale: props.locale,
precision: props.precision, precision: props.precision,
hideCurrencySymbolOnFocus: false, hideCurrencySymbolOnFocus: false,

View File

@@ -14,11 +14,12 @@ import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag' import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import exchangeService from '@/services/ExchangeService' import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService' import transactionService from '@/services/TransactionService'
import queueNotifService from '@/services/QueueNotifService'
import mapService from '@/services/MapService' import mapService from '@/services/MapService'
import { money } from '@/utils/format' import { money } from '@/utils/format'
import { findContrastColor } from '@/utils' import { findContrastColor } from '@/utils'
import notif from '@/utils/notif' import notif from '@/utils/notif'
import { validateAndNotify } from '@/utils/validate'
import { transactionFormSchema, MAX_TRANSACTION_AMOUNT } from '@/schemas/transaction'
import FabButton from '@/components/FabButton.vue' import FabButton from '@/components/FabButton.vue'
import TransactionSplit from '@/components/TransactionSplit.vue' import TransactionSplit from '@/components/TransactionSplit.vue'
import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue' import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue'
@@ -46,7 +47,7 @@ const date = ref(today)
const currency = ref<ICurrency | null>(null) const currency = ref<ICurrency | null>(null)
const payBy = ref('') const payBy = ref('')
const payFor = reactive<ISplit[]>([]) const payFor = reactive<ISplit[]>([])
const maxAmount = 1_000_000 const maxAmount = MAX_TRANSACTION_AMOUNT
const displayLocationModal = ref(false) const displayLocationModal = ref(false)
const isSetLocation = ref(false) const isSetLocation = ref(false)
const location = ref<ILocation | null>(null) const location = ref<ILocation | null>(null)
@@ -120,23 +121,12 @@ const validLocation = async () => {
const validate = (): boolean => { const validate = (): boolean => {
const el = document.querySelector(':focus') as HTMLInputElement | null const el = document.querySelector(':focus') as HTMLInputElement | null
el?.blur() el?.blur()
if (!name.value || !amount.value || !payBy.value || (!currency.value && !date.value && payFor.length === 0)) { return validateAndNotify(
if (amount.value === 0) { transactionFormSchema,
queueNotifService.error('Le montant doit être supérieur à 0.') { name: name.value, amount: amount.value, payBy: payBy.value },
} else { t,
queueNotifService.error('Tous les champs sont requis.') { amount: { max: money(maxAmount, currency.value) } }
} )
return false
}
if (isNaN(amount.value) || amount.value > maxAmount) {
queueNotifService.error(`Veuillez saisir un montant numérique inférieur à ${money(maxAmount, currency.value)}.`)
return false
}
if (amount.value < 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
return false
}
return true
} }
const submitTransaction = async (finish: () => void) => { const submitTransaction = async (finish: () => void) => {

View File

@@ -47,6 +47,20 @@ export default {
}, },
validation: { validation: {
max_char: max_char:
'no character available | 1 character max | {max} character maximum' 'no character available | 1 character max | {max} character maximum',
transaction_name_required: 'Transaction name is required.',
amount_required: 'Amount is required.',
amount_must_be_positive: 'Amount must be greater than 0.',
amount_too_large: 'Amount must be less than {max}.',
currency_required: 'Currency is required.',
date_required: 'Date is required.',
pay_by_required: 'Payer is required.',
pay_for_required: 'At least one share must be assigned.',
user_id_required: 'A username is required.',
passwords_mismatch: 'Passwords must match.',
account_name_required: 'Account name is required.',
account_needs_one_user: 'At least one person must be added to the account.',
aliases_must_be_unique: 'Aliases must be unique.',
user_ids_must_be_unique: 'Usernames must be unique.'
} }
} }

View File

@@ -64,6 +64,20 @@ export default {
pseudo: 'identifiant' pseudo: 'identifiant'
}, },
validation: { validation: {
max_char: 'limite atteinte | 1 caractère maximum | {max} caractères maximum' max_char: 'limite atteinte | 1 caractère maximum | {max} caractères maximum',
transaction_name_required: 'Le nom de la dépense est requis.',
amount_required: 'Le montant est requis.',
amount_must_be_positive: 'Le montant doit être supérieur à 0.',
amount_too_large: 'Le montant doit être inférieur à {max}.',
currency_required: 'La devise est requise.',
date_required: 'La date est requise.',
pay_by_required: 'Le payeur est requis.',
pay_for_required: 'Au moins une part doit être attribuée.',
user_id_required: 'Un pseudo est obligatoire.',
passwords_mismatch: 'Les mots de passe doivent être identiques.',
account_name_required: 'Le nom du compte est requis.',
account_needs_one_user: 'Au moins une personne doit être ajoutée au compte.',
aliases_must_be_unique: 'Les alias doivent être uniques.',
user_ids_must_be_unique: 'Les identifiants doivent être uniques.'
} }
} }

20
src/schemas/account.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type } from 'arktype'
export const accountFormSchema = type({
name: 'unknown',
aliases: 'unknown',
userIds: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.name !== 'string' || data.name.trim().length === 0) {
ctx.reject({ message: 'validation.account_name_required', path: ['name'] })
}
if (!Array.isArray(data.aliases) || data.aliases.length === 0) {
ctx.reject({ message: 'validation.account_needs_one_user', path: ['aliases'] })
} else if (new Set(data.aliases).size !== data.aliases.length) {
ctx.reject({ message: 'validation.aliases_must_be_unique', path: ['aliases'] })
}
if (Array.isArray(data.userIds) && new Set(data.userIds).size !== data.userIds.length) {
ctx.reject({ message: 'validation.user_ids_must_be_unique', path: ['userIds'] })
}
return !ctx.hasError()
})

View File

@@ -0,0 +1,24 @@
import { type } from 'arktype'
export const MAX_TRANSACTION_AMOUNT = 1_000_000
export const transactionFormSchema = type({
name: 'unknown',
amount: 'unknown',
payBy: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.name !== 'string' || data.name.trim().length === 0) {
ctx.reject({ message: 'validation.transaction_name_required', path: ['name'] })
}
if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) {
ctx.reject({ message: 'validation.amount_required', path: ['amount'] })
} else if (data.amount <= 0) {
ctx.reject({ message: 'validation.amount_must_be_positive', path: ['amount'] })
} else if (data.amount > MAX_TRANSACTION_AMOUNT) {
ctx.reject({ message: 'validation.amount_too_large', path: ['amount'] })
}
if (typeof data.payBy !== 'string' || data.payBy.length === 0) {
ctx.reject({ message: 'validation.pay_by_required', path: ['payBy'] })
}
return !ctx.hasError()
})

15
src/schemas/user.ts Normal file
View File

@@ -0,0 +1,15 @@
import { type } from 'arktype'
export const signupFormSchema = type({
userId: 'unknown',
password: 'unknown',
confirmPassword: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.userId !== 'string' || data.userId.trim().length === 0) {
ctx.reject({ message: 'validation.user_id_required', path: ['userId'] })
}
if (data.password !== data.confirmPassword) {
ctx.reject({ message: 'validation.passwords_mismatch', path: ['confirmPassword'] })
}
return !ctx.hasError()
})

View File

@@ -11,11 +11,11 @@ PouchDb.plugin(PouchDbAuthentication)
const emit = debounce((ev: typeof SYNC, arr?: string[]) => bus.emit(ev, arr), 150) const emit = debounce((ev: typeof SYNC, arr?: string[]) => bus.emit(ev, arr), 150)
const remoteConfig = { const remoteConfig: PouchDB.Configuration.RemoteDatabaseConfiguration = {
skip_setup: true, skip_setup: true,
ajax: { fetch: (url, opts) => {
cache: true, const init: RequestInit = { ...(opts ?? {}), credentials: 'include' }
withCredentials: true return PouchDb.fetch(url as RequestInfo, init)
} }
} }
const LOCALE_DB = 'VAQUANT_LOCALE_DB' const LOCALE_DB = 'VAQUANT_LOCALE_DB'

29
src/utils/validate.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ArkErrors } from 'arktype'
import type { Type } from 'arktype'
import type { Composer } from 'vue-i18n'
import queueNotifService from '@/services/QueueNotifService'
type TranslateFn = Composer['t']
type MessageParams = Record<string, unknown>
export type ParamsByPath = Record<string, MessageParams>
export function validateAndNotify(
schema: Type,
data: unknown,
t: TranslateFn,
paramsByPath: ParamsByPath = {}
): boolean {
const result = schema(data)
if (!(result instanceof ArkErrors)) return true
const seen = new Set<string>()
for (const error of result) {
const pathKey = error.path.join('.')
// Skip duplicate field-level errors at the same path so the user gets one toast per field.
if (seen.has(pathKey)) continue
seen.add(pathKey)
const params = paramsByPath[pathKey] ?? {}
queueNotifService.error(t(error.message, params))
}
return false
}

View File

@@ -7,6 +7,8 @@ import type IUser from '@/models/IUser'
import userService from '@/services/UserService' import userService from '@/services/UserService'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import { slug } from '@/utils' import { slug } from '@/utils'
import { validateAndNotify } from '@/utils/validate'
import { signupFormSchema } from '@/schemas/user'
import LangChanger from '@/components/LangChanger.vue' import LangChanger from '@/components/LangChanger.vue'
import AccountList from '@/components/AccountList.vue' import AccountList from '@/components/AccountList.vue'
import ConfirmButton from '@/components/ConfirmButton.vue' import ConfirmButton from '@/components/ConfirmButton.vue'
@@ -51,17 +53,16 @@ const signin = async () => {
isLoading.value = false isLoading.value = false
} }
const validateRegistration = (): boolean => { const validateRegistration = (): boolean =>
if (!userId.value) { validateAndNotify(
queueNotifService.error('Un pseudo est obligatoire.') signupFormSchema,
return false {
} userId: userId.value,
if (password.value !== confirmPassword.value) { password: password.value,
queueNotifService.error('Les mots de passe doivent être identique.') confirmPassword: confirmPassword.value
return false },
} t
return true )
}
const register = async (confirmed: boolean) => { const register = async (confirmed: boolean) => {
if (!validateRegistration()) return if (!validateRegistration()) return

View File

@@ -11,6 +11,8 @@ import type IUser from '@/models/IUser'
import accountService from '@/services/AccountService' import accountService from '@/services/AccountService'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import { slug } from '@/utils' import { slug } from '@/utils'
import { validateAndNotify } from '@/utils/validate'
import { accountFormSchema } from '@/schemas/account'
import ColorPicker from '@/components/ColorPicker.vue' import ColorPicker from '@/components/ColorPicker.vue'
import AccountUserNew from '@/components/AccountUserNew.vue' import AccountUserNew from '@/components/AccountUserNew.vue'
@@ -55,27 +57,13 @@ watch(
) )
const validate = (allUsers: IUser[]): boolean => { const validate = (allUsers: IUser[]): boolean => {
if (!name.value) {
queueNotifService.error('Le nom doit être rempli.')
return false
}
const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : '')) const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : ''))
if (aliases.length === 0) { const userIds = user.value ? allUsers.map((u) => u.userId) : undefined
queueNotifService.error('Au moins une personne doit être ajoutée au compte.') return validateAndNotify(
return false accountFormSchema,
} { name: name.value, aliases, userIds },
if (aliases.length !== new Set(aliases).size) { t
queueNotifService.error('Les alias doivent être uniques.') )
return false
}
if (user.value) {
const userIds = allUsers.map((u) => u.userId)
if (userIds.length !== new Set(userIds).size) {
queueNotifService.error('Les identifiants doivent être uniques.')
return false
}
}
return true
} }
const submitAccount = async () => { const submitAccount = async () => {