Compare commits
3 Commits
360244dc08
...
7c3d34d02e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3d34d02e | ||
|
|
0fc7118fd4 | ||
|
|
d61f391203 |
@@ -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
31
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
20
src/schemas/account.ts
Normal 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()
|
||||||
|
})
|
||||||
24
src/schemas/transaction.ts
Normal file
24
src/schemas/transaction.ts
Normal 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
15
src/schemas/user.ts
Normal 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()
|
||||||
|
})
|
||||||
29
src/utils/validate.ts
Normal file
29
src/utils/validate.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user