Compare commits

...

7 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
Julien Calixte
360244dc08 refactor(exchange): switch rates API to frankfurter.dev
Drop HRK and RUB from the currency list since frankfurter.dev no longer
publishes them, and request only the rates we need via the symbols param.
2026-06-01 21:47:19 +02:00
Julien Calixte
39accae46e refactor(map): switch reverse geocoding from Bing to Mapbox
Reuse the existing Mapbox token instead of maintaining a separate Bing
Maps key. Note the coordinate order flips to lon,lat for the Mapbox
geocoding endpoint.
2026-06-01 21:47:09 +02:00
19 changed files with 206 additions and 120 deletions

1
.env
View File

@@ -1,3 +1,2 @@
VITE_COUCHDB=https://couch.apoena.dev
VITE_MAP_KEY=AnLDo_m_IGvMsQPLuBak9igWj3gNYvqBodj0esZZ7VfI1OkqVWvg04eTZ4U9R0Y2
VITE_MAPBOX_KEY=pk.eyJ1IjoiamNhbGl4dGUiLCJhIjoiY2s3cmo1aHhlMDZ3dDNtc2Z0OXl3M3c5dSJ9.rkkIAZ4lKSJZssoFvH7Fpw

View File

@@ -36,5 +36,4 @@ Create `.env` (gitignored) with:
```
VITE_COUCHDB=https://your-couchdb-host
VITE_MAPBOX_KEY=pk.your-mapbox-token
VITE_MAP_KEY=your-bing-maps-key
```

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

@@ -15,8 +15,6 @@ export default [
{ name: 'Swiss franc', code: 'CHF' },
{ name: 'Icelandic krona', code: 'ISK', symbol: 'kr' },
{ name: 'Norwegian krone', code: 'NOK', symbol: 'øre' },
{ name: 'Croatian kuna', code: 'HRK', symbol: 'kn' },
{ name: 'Russian rouble', code: 'RUB' },
{ name: 'Turkish lira', code: 'TRY' },
{ name: 'Australian dollar', code: 'AUD', symbol: 'A$' },
{ name: 'Brazilian real', code: 'BRL', symbol: 'R$' },

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.'
}
}

View File

@@ -1,44 +1,11 @@
export default interface ILocationQuery {
authenticationResultCode: string
brandLogoUri: string
copyright: string
resourceSets: [
{
estimatedTotal: number
resources: [
{
__type: string
bbox: number[]
name: string
point: {
type: string
coordinates: number[]
}
address: {
addressLine: string
adminDistrict: string
adminDistrict2: string
countryRegion: string
formattedAddress: string
locality: string
postalCode: string
}
confidence: string
entityType: string
geocodePoints: [
{
type: string
coordinates: number[]
calculationMethod: string
usageTypes: string[]
}
]
matchCodes: string[]
}
]
}
]
statusCode: number
statusDescription: string
traceId: string
type: string
features: Array<{
id: string
type: string
place_type: string[]
text: string
place_name: string
center: number[]
}>
}

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'

View File

@@ -3,7 +3,7 @@ import couchService from '@/services/CouchService'
import formatDate from '@/utils/format-date'
class ExchangeService {
private baseUrl: string = 'https://api.exchangeratesapi.io/'
private baseUrl: string = 'https://api.frankfurter.dev/v1/'
/**
* Get an exchange with defined date and desired currencies
@@ -42,12 +42,16 @@ class ExchangeService {
}
}
} else {
const url = new URL(
date ? `${this.baseUrl}${formattedDate}` : 'latest'
)
const url = new URL(`${this.baseUrl}${date ? formattedDate : 'latest'}`)
if (base) {
url.searchParams.append('base', base)
}
if (rates.length) {
url.searchParams.append(
'symbols',
rates.filter((r) => r !== base).join(',')
)
}
const response = await fetch(url.toString())
if (response.ok) {
exchange = await response.json()

View File

@@ -2,7 +2,7 @@ import ILocation from '@/models/ILocation'
import ILocationQuery from '@/models/ILocationQuery'
class MapService {
private key: string = import.meta.env.VITE_MAP_KEY || ''
private token: string = import.meta.env.VITE_MAPBOX_KEY || ''
public async getPosition(): Promise<ILocation | null> {
const position = await this.getCurrentPosition()
@@ -28,20 +28,11 @@ class MapService {
return ''
}
const json: ILocationQuery = await result.json()
if (
!json ||
!json.resourceSets ||
!json.resourceSets.length ||
!json.resourceSets[0].resources.length
) {
return ''
}
const address = json.resourceSets[0].resources[0].address
return address ? address.locality : ''
return json?.features?.[0]?.text ?? ''
}
private url(latitude: number, longitude: number) {
return `https://dev.virtualearth.net/REST/v1/Locations/${latitude},${longitude}?key=${this.key}`
return `https://api.mapbox.com/geocoding/v5/mapbox.places/${longitude},${latitude}.json?types=place&limit=1&access_token=${this.token}`
}
private getCurrentPosition(): Promise<GeolocationPosition | null> {

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 () => {