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": {
"@tabler/icons-webfont": "^3.30.0",
"@vueuse/core": "^12.0.0",
"arktype": "^2.2.0",
"axios": "^1.7.7",
"date-fns": "^4.1.0",
"events": "^3.3.0",

31
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@vueuse/core':
specifier: ^12.0.0
version: 12.8.2(typescript@5.9.3)
arktype:
specifier: ^2.2.0
version: 2.2.0
axios:
specifier: ^1.7.7
version: 1.16.1
@@ -153,6 +156,12 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -2001,6 +2010,12 @@ packages:
argsarray@0.0.1:
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:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@@ -4718,6 +4733,12 @@ snapshots:
jsonpointer: 5.0.1
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':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -6512,6 +6533,16 @@ snapshots:
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:
dependencies:
call-bound: 1.0.4

View File

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

View File

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

View File

@@ -47,6 +47,20 @@ export default {
},
validation: {
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'
},
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 remoteConfig = {
const remoteConfig: PouchDB.Configuration.RemoteDatabaseConfiguration = {
skip_setup: true,
ajax: {
cache: true,
withCredentials: true
fetch: (url, opts) => {
const init: RequestInit = { ...(opts ?? {}), credentials: 'include' }
return PouchDb.fetch(url as RequestInfo, init)
}
}
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 queueNotifService from '@/services/QueueNotifService'
import { slug } from '@/utils'
import { validateAndNotify } from '@/utils/validate'
import { signupFormSchema } from '@/schemas/user'
import LangChanger from '@/components/LangChanger.vue'
import AccountList from '@/components/AccountList.vue'
import ConfirmButton from '@/components/ConfirmButton.vue'
@@ -51,17 +53,16 @@ const signin = async () => {
isLoading.value = false
}
const validateRegistration = (): boolean => {
if (!userId.value) {
queueNotifService.error('Un pseudo est obligatoire.')
return false
}
if (password.value !== confirmPassword.value) {
queueNotifService.error('Les mots de passe doivent être identique.')
return false
}
return true
}
const validateRegistration = (): boolean =>
validateAndNotify(
signupFormSchema,
{
userId: userId.value,
password: password.value,
confirmPassword: confirmPassword.value
},
t
)
const register = async (confirmed: boolean) => {
if (!validateRegistration()) return

View File

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