mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
fix: account name don't change when Update Account #248
This commit is contained in:
+12
-2
@@ -10,7 +10,10 @@
|
||||
"preview": "vite preview",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"knip": "knip"
|
||||
"knip": "knip",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -81,6 +84,9 @@
|
||||
"@tanstack/react-query-devtools": "^5.62.3",
|
||||
"@tanstack/router-devtools": "^1.86.1",
|
||||
"@tanstack/router-plugin": "^1.86.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -88,18 +94,22 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^5.41.1",
|
||||
"msw": "^2.14.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.11"
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
Generated
+1182
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const baseData = {
|
||||
email: 'test@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Schema - date_since validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts fixed date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '2024-01-01' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts relative date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 6 } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_since', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 0 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with negative value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: -1 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with non-integer value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 1.5 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects fixed date_since with empty string', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - date_before validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts valid date_before', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 30 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_before', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects date_before with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 0 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - use_dangerous and enabled flags', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts use_dangerous: true', () => {
|
||||
const result = schema.safeParse({ ...baseData, use_dangerous: true })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...baseData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts auto_download_new_mailboxes: false', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
auto_download_new_mailboxes: false,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - missing required nested fields', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects missing imap entirely', () => {
|
||||
const { imap, ...noImap } = baseData
|
||||
const result = schema.safeParse(noImap)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing imap.auth', () => {
|
||||
const { auth, ...noAuth } = baseData.imap
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
imap: noAuth,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema, getAuthConfigSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validAccountData = {
|
||||
email: 'user@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Form Schema', () => {
|
||||
describe('email field', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({ ...validAccountData, email: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'not-an-email',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without @', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'username',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.host field', () => {
|
||||
it('rejects empty IMAP host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid hostname', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts IP address as host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '192.168.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.port field', () => {
|
||||
it('accepts port 993 (standard IMAP SSL)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 143 (standard IMAP)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 143 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 0 (auto-detect)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 0 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects negative port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: -1 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 99999 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-integer port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 993.5 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.encryption field', () => {
|
||||
it('accepts Ssl', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts StartTls', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'StartTls' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts None', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'None' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid encryption value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'TLS' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_interval_min field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-integer value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 30.5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_batch_size field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects value greater than 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 500,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 200,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder_limit field', () => {
|
||||
it('accepts undefined folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: null,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects folder_limit less than 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 50,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts folder_limit of exactly 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_name and login_name fields', () => {
|
||||
it('accepts undefined account_name and login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided account_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
account_name: 'My Work Email',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
login_name: 'username',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Config Schema (password validation)', () => {
|
||||
describe('when creating (isEdit = false)', () => {
|
||||
const schema = getAuthConfigSchema(false, t)
|
||||
|
||||
it('requires password when auth_type is Password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires password when auth_type is Password and password undefined', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('does not require password when auth_type is OAuth2', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'OAuth2',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when editing (isEdit = true)', () => {
|
||||
const schema = getAuthConfigSchema(true, t)
|
||||
|
||||
it('does not require password even with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts with undefined password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
@@ -35,112 +34,9 @@ import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAccountSchema, type AccountFormValues } from './schema';
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
]);
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
]);
|
||||
|
||||
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
);
|
||||
|
||||
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
|
||||
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
|
||||
});
|
||||
|
||||
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
|
||||
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }),
|
||||
z.object({ relative: getRelativeDateSchema(t) }),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type Account = {
|
||||
login_name?: string;
|
||||
account_name?: string;
|
||||
email: string;
|
||||
imap: {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: 'Ssl' | 'StartTls' | 'None';
|
||||
auth: {
|
||||
auth_type: 'Password' | 'OAuth2';
|
||||
password?: string;
|
||||
};
|
||||
use_proxy?: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
use_dangerous: boolean;
|
||||
date_since?: {
|
||||
fixed?: string;
|
||||
relative?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
};
|
||||
date_before?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
folder_limit?: number;
|
||||
download_interval_min: number;
|
||||
download_batch_size: number;
|
||||
auto_download_new_mailboxes: boolean;
|
||||
};
|
||||
|
||||
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: getDateSelectionSchema(t).optional(),
|
||||
date_before: getRelativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
download_batch_size: z
|
||||
.number({ invalid_type_error: t('validation.singleRequestBatchSizeMustBeNumber') })
|
||||
.int()
|
||||
.min(10, { message: t('validation.singleRequestBatchSizeTooSmall') })
|
||||
.max(200, { message: t('validation.singleRequestBatchSizeTooLarge') }),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
});
|
||||
export type Account = AccountFormValues;
|
||||
|
||||
type Step = {
|
||||
id: `step-${number}`;
|
||||
@@ -205,6 +101,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
}
|
||||
|
||||
return {
|
||||
account_name: currentRow.account_name ?? undefined,
|
||||
login_name: currentRow.login_name ?? undefined,
|
||||
email: currentRow.email,
|
||||
imap,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
])
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
])
|
||||
|
||||
export const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim()
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
|
||||
export const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z
|
||||
.string({ required_error: t('validation.imapHostRequired') })
|
||||
.min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, { message: t('validation.imapPortMustBePositive') })
|
||||
.max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
const relativeDateSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
unit: z.enum(['Days', 'Months', 'Years'], {
|
||||
message: t('accounts.selectUnit'),
|
||||
}),
|
||||
value: z
|
||||
.number({ message: t('accounts.enterValue') })
|
||||
.int()
|
||||
.min(1, t('accounts.mustBeAtLeast1')),
|
||||
})
|
||||
|
||||
const dateSelectionSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
fixed: z
|
||||
.string({ message: t('accounts.selectDate') })
|
||||
.min(1, { message: t('accounts.selectDate') })
|
||||
.optional(),
|
||||
relative: relativeDateSchema(t).optional(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z
|
||||
.string({ required_error: t('validation.emailRequired') })
|
||||
.email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: dateSelectionSchema(t).optional(),
|
||||
date_before: relativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.incrementalSyncMustBeNumber'),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.incrementalSyncMustBeAtLeast10'),
|
||||
}),
|
||||
download_batch_size: z
|
||||
.number({
|
||||
invalid_type_error: t(
|
||||
'validation.singleRequestBatchSizeMustBeNumber'
|
||||
),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.singleRequestBatchSizeTooSmall'),
|
||||
})
|
||||
.max(200, {
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
})
|
||||
|
||||
export type AccountFormValues = z.infer<
|
||||
ReturnType<typeof getAccountSchema>
|
||||
>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFormSchema } from '../schema'
|
||||
|
||||
// Simple mock t function that returns the key
|
||||
const t = (key: string, _options?: Record<string, any>) => key
|
||||
|
||||
describe('Login Form Schema', () => {
|
||||
const schema = getFormSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({ username: '', password: 'abcd' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const usernameErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(usernameErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid username with password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'pass1234' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts email as username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'user@example.com',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('rejects empty password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: '' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const passwordErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'password'
|
||||
)
|
||||
expect(passwordErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 4 characters', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'ab' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 4 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'abcd',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts long password', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing fields', () => {
|
||||
it('rejects empty object', () => {
|
||||
const result = schema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects object with only username', () => {
|
||||
const result = schema.safeParse({ username: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getFormSchema = (
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
})
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof getFormSchema>>
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
|
||||
import { HTMLAttributes, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { cn, toSearchParams } from '@/lib/utils'
|
||||
import { getFormSchema, type LoginFormValues } from './schema'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -47,17 +47,6 @@ import { useTheme } from '@/context/theme-context'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
});
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { setTheme } = useTheme();
|
||||
@@ -68,7 +57,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const redirect = toSearchParams(search).get('redirect') || '/';
|
||||
|
||||
const formSchema = getFormSchema(t)
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
@@ -81,7 +70,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(data: LoginFormValues) {
|
||||
setIsLoading(true)
|
||||
|
||||
mutation.mutate(data, {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getOAuth2Schema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('OAuth2 Form Schema', () => {
|
||||
const schema = getOAuth2Schema(t)
|
||||
|
||||
const validData = {
|
||||
client_id: 'my-client-id',
|
||||
auth_url: 'https://accounts.example.com/o/oauth2/auth',
|
||||
token_url: 'https://oauth2.example.com/token',
|
||||
redirect_uri: 'https://myapp.example.com/oauth2/callback',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
describe('client_id field', () => {
|
||||
it('rejects empty client_id', () => {
|
||||
const result = schema.safeParse({ ...validData, client_id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid client_id', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client_secret field', () => {
|
||||
it('accepts undefined client_secret', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided client_secret', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
client_secret: 'my-secret',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth_url field', () => {
|
||||
it('rejects empty auth_url', () => {
|
||||
const result = schema.safeParse({ ...validData, auth_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for auth_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
auth_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid auth_url', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token_url field', () => {
|
||||
it('rejects empty token_url', () => {
|
||||
const result = schema.safeParse({ ...validData, token_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for token_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
token_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redirect_uri field', () => {
|
||||
it('rejects empty redirect_uri', () => {
|
||||
const result = schema.safeParse({ ...validData, redirect_uri: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for redirect_uri', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
redirect_uri: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopes field', () => {
|
||||
it('accepts empty scopes array', () => {
|
||||
const result = schema.safeParse({ ...validData, scopes: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid scopes', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: 'https://mail.google.com/' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects scope with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extra_params field', () => {
|
||||
it('accepts empty extra_params array', () => {
|
||||
const result = schema.safeParse({ ...validData, extra_params: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid extra_params', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects param with empty key', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: '', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects param with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabled field', () => {
|
||||
it('accepts enabled: true', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...validData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('rejects description longer than 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts description of exactly 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(255),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('use_proxy field', () => {
|
||||
it('accepts undefined use_proxy', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts numeric use_proxy', () => {
|
||||
const result = schema.safeParse({ ...validData, use_proxy: 1 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -53,115 +52,22 @@ import { AxiosError } from 'axios'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import useProxyList from '@/hooks/use-proxy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getParamSchema = (t: (key: string) => string) => z.object({
|
||||
key: z.string({ required_error: t('oauth2.keyIsRequired') }).min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const paramSchema = z.object({
|
||||
key: z.string({ required_error: 'Key is required' }).min(1, "Key cannot be empty"),
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const getScopeSchema = (t: (key: string) => string) => z.object({
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const scopeSchema = z.object({
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const extraparamSchema = z.record(z.string()).optional();
|
||||
const authorizescopeSchema = z.array(z.string()).optional();
|
||||
|
||||
import { getOAuth2Schema, type OAuth2FormValues } from './schema'
|
||||
|
||||
function convertToExtraParamsSchema(
|
||||
record: z.infer<typeof extraparamSchema>
|
||||
): z.infer<typeof paramSchema>[] {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(record).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
record: Record<string, string> | undefined
|
||||
): { key: string; value: string }[] {
|
||||
if (!record) return []
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
|
||||
function convertToScopeSchema(authorizeScopes: z.infer<typeof authorizescopeSchema>): z.infer<typeof scopeSchema>[] {
|
||||
if (!authorizeScopes || authorizeScopes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authorizeScopes.map((scope) => ({
|
||||
value: scope,
|
||||
}));
|
||||
function convertToScopeSchema(
|
||||
scopes: string[] | undefined
|
||||
): { value: string }[] {
|
||||
if (!scopes || scopes.length === 0) return []
|
||||
return scopes.map((scope) => ({ value: scope }))
|
||||
}
|
||||
|
||||
const getOAuth2Schema = (t: (key: string) => string) => z.object({
|
||||
description: z.string().max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
}).min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
|
||||
scopes: z.array(getScopeSchema(t)).optional(),
|
||||
extra_params: z.array(getParamSchema(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const oauth2Schema = z.object({
|
||||
description: z.string().max(255, { message: "Description must not exceed 255 characters." }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: "Client ID is required",
|
||||
}).min(1, { message: "Client ID cannot be empty" }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: "Authorization URL is required",
|
||||
})
|
||||
.min(1, { message: "Authorization URL cannot be empty" })
|
||||
.url({ message: "Invalid Authorization URL format" }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: "Token URL is required",
|
||||
})
|
||||
.min(1, { message: "Token URL cannot be empty" })
|
||||
.url({ message: "Invalid Token URL format" }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: "Redirect URI is required",
|
||||
})
|
||||
.min(1, { message: "Redirect URI cannot be empty" })
|
||||
.url({ message: "Invalid Redirect URI format" }),
|
||||
|
||||
scopes: z.array(scopeSchema).optional(),
|
||||
extra_params: z.array(paramSchema).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
export type OAuth2Form = z.infer<typeof oauth2Schema>;
|
||||
|
||||
|
||||
interface Props {
|
||||
currentRow?: OAuth2Entity
|
||||
open: boolean
|
||||
@@ -185,7 +91,7 @@ const defaultValues = {
|
||||
export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const form = useForm<OAuth2Form>({
|
||||
const form = useForm<OAuth2FormValues>({
|
||||
resolver: zodResolver(getOAuth2Schema(t)),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
@@ -255,7 +161,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const onSubmit = (values: OAuth2Form) => {
|
||||
const onSubmit = (values: OAuth2FormValues) => {
|
||||
if (!isEdit) {
|
||||
if (!values.client_secret) {
|
||||
form.setError('client_secret', {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const paramEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
key: z
|
||||
.string({ required_error: t('oauth2.keyIsRequired') })
|
||||
.min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
const scopeEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
export const getOAuth2Schema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
description: z
|
||||
.string()
|
||||
.max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') })
|
||||
.optional(),
|
||||
client_id: z
|
||||
.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
token_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
redirect_uri: z
|
||||
.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
scopes: z.array(scopeEntry(t)).optional(),
|
||||
extra_params: z.array(paramEntry(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
export type OAuth2FormValues = z.infer<ReturnType<typeof getOAuth2Schema>>
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { profileSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Profile Form Schema', () => {
|
||||
const schema = profileSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: '',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects username shorter than 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'ab',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'abc',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects username longer than 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(33),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(32),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('email field', () => {
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'not-an-email',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without domain', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
// Empty password should be transformed to undefined
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects password longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('transforms non-empty password to the string value', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'myNewPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('myNewPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -42,41 +41,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { FileWithPreview } from '@/hooks/use-file-upload'
|
||||
import AvatarUpload from './avatar-upload'
|
||||
import { PermissionsDialog } from '../access/permissions-dialog'
|
||||
|
||||
const profileSchema = (t: (key: string) => string) => z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
import { profileSchema, type ProfileFormValues } from './schema'
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const profileSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { proxyFormSchema } from '../schema'
|
||||
|
||||
describe('Proxy Form Schema', () => {
|
||||
describe('url field - basic validation', () => {
|
||||
it('rejects empty URL', () => {
|
||||
const result = proxyFormSchema.safeParse({ url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid socks5 URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid http URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'http://proxy.example.com:8080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - protocol validation', () => {
|
||||
it('rejects https protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'https://proxy.example.com:443',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('http:// or socks5://')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ftp protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'ftp://files.example.com',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects URL without protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: '127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Invalid URL format')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - port validation', () => {
|
||||
it('rejects port 0', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:0',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:99999',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts port 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:65535',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 1', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to port 1080 when no port specified', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - hostname validation', () => {
|
||||
it('accepts IP address hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://192.168.1.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts domain hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy.internal:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostname with invalid characters', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy_host:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Hostname contains invalid characters')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - auth validation', () => {
|
||||
it('rejects username without password', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password cannot be empty')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when username provided', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:short@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password must be at least 8')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid auth credentials', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:password123@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts URL without auth (no credentials)', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -47,75 +46,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { add_proxy, update_proxy } from '@/api/system/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
|
||||
const proxyFormSchema = z.object({
|
||||
url: z.string()
|
||||
.min(1, "Proxy address cannot be empty")
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (e) {
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid URL format",
|
||||
path: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "URL must start with http:// or socks5://",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Hostname contains invalid characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const port = parseInt(url.port || '1080');
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Port must be between 1-65535",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password cannot be empty when username is provided",
|
||||
path: [],
|
||||
});
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password must be at least 8 characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export type ProxyForm = z.infer<typeof proxyFormSchema>;
|
||||
import { proxyFormSchema, type ProxyFormValues } from './schema'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -140,7 +71,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ProxyForm>({
|
||||
const form = useForm<ProxyFormValues>({
|
||||
resolver: zodResolver(proxyFormSchema),
|
||||
defaultValues: isEdit
|
||||
? mapCurrentRowToFormValues(currentRow)
|
||||
@@ -186,7 +117,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = (values: ProxyForm) => {
|
||||
const onSubmit = (values: ProxyFormValues) => {
|
||||
const url = values.url;
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(url);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const proxyFormSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.min(1, 'Proxy address cannot be empty')
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid URL format',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'URL must start with http:// or socks5://',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Hostname contains invalid characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
const port = parseInt(url.port || '1080')
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Port must be between 1-65535',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password cannot be empty when username is provided',
|
||||
path: [],
|
||||
})
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password must be at least 8 characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRoleFormSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Role Form Schema', () => {
|
||||
const schema = getRoleFormSchema(t)
|
||||
|
||||
describe('name field', () => {
|
||||
it('rejects empty name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: '',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('role_type field', () => {
|
||||
it('accepts Global role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Admin',
|
||||
role_type: 'Global',
|
||||
permissions: ['system:access'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Account role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Test',
|
||||
role_type: 'Invalid',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permissions field', () => {
|
||||
it('rejects empty permissions array', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts single permission', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts multiple permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Manager',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read', 'data:manage', 'account:manage'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts all available permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Super Admin',
|
||||
role_type: 'Global',
|
||||
permissions: [
|
||||
'system:access',
|
||||
'system:root',
|
||||
'user:manage',
|
||||
'user:view',
|
||||
'token:manage',
|
||||
'account:create',
|
||||
'account:manage:all',
|
||||
'data:read:all',
|
||||
'data:manage:all',
|
||||
'data:raw:download:all',
|
||||
'data:delete:all',
|
||||
'data:export:batch:all',
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts description string', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
description: 'Read-only access to data',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
} from '@/components/ui/radio-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRoleFormSchema, type RoleFormValues } from './schema'
|
||||
|
||||
interface Props {
|
||||
currentRow?: UserRole
|
||||
@@ -97,16 +97,9 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
const roleFormSchema = getRoleFormSchema(t)
|
||||
|
||||
type RoleForm = z.infer<typeof roleFormSchema>
|
||||
|
||||
const form = useForm<RoleForm>({
|
||||
const form = useForm<RoleFormValues>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
defaultValues: {
|
||||
name: isEdit ? currentRow.name : '',
|
||||
@@ -117,7 +110,7 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: RoleForm) =>
|
||||
mutationFn: (values: RoleFormValues) =>
|
||||
isEdit ? update_role(currentRow!.id, values) : create_role(values),
|
||||
onSuccess: () => {
|
||||
toast({ title: t(isEdit ? 'roles.actions.success_update' : 'roles.actions.success_create') })
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getRoleFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type RoleFormValues = z.infer<ReturnType<typeof getRoleFormSchema>>
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getCreateUserSchema, getUpdateUserSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validBaseUser = {
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
global_roles: [1],
|
||||
}
|
||||
|
||||
const validCreateUser = {
|
||||
...validBaseUser,
|
||||
password: 'securePassword123',
|
||||
}
|
||||
|
||||
const invalidCases = [
|
||||
{ desc: 'empty username', data: { ...validCreateUser, username: '' } },
|
||||
{
|
||||
desc: 'username shorter than 3',
|
||||
data: { ...validCreateUser, username: 'ab' },
|
||||
},
|
||||
{
|
||||
desc: 'username longer than 32',
|
||||
data: { ...validCreateUser, username: 'a'.repeat(33) },
|
||||
},
|
||||
{ desc: 'empty email', data: { ...validCreateUser, email: '' } },
|
||||
{
|
||||
desc: 'invalid email format',
|
||||
data: { ...validCreateUser, email: 'not-an-email' },
|
||||
},
|
||||
{
|
||||
desc: 'empty global_roles',
|
||||
data: { ...validCreateUser, global_roles: [] },
|
||||
},
|
||||
{
|
||||
desc: 'empty password on create',
|
||||
data: { ...validBaseUser, password: '' },
|
||||
},
|
||||
{
|
||||
desc: 'short password on create',
|
||||
data: { ...validBaseUser, password: 'short' },
|
||||
},
|
||||
{
|
||||
desc: 'password longer than 256 on create',
|
||||
data: { ...validBaseUser, password: 'a'.repeat(257) },
|
||||
},
|
||||
]
|
||||
|
||||
describe('Create User Schema', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts valid user data', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it.each(invalidCases)('rejects $desc', ({ data }) => {
|
||||
const result = schema.safeParse(data)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'abc',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'a'.repeat(32),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update User Schema', () => {
|
||||
const schema = getUpdateUserSchema(t)
|
||||
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts undefined password', () => {
|
||||
const result = schema.safeParse(validBaseUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'newPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('newPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - ACL', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
describe('ip_whitelist validation', () => {
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1\n10.0.0.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid IPv6 address', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid IP format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: 'not-an-ip' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid IP with too many octets', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects IP with octet > 255', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '300.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts empty ACL (no security policies)', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate_limit validation', () => {
|
||||
it('accepts valid rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms ACL with only rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success && result.data.acl) {
|
||||
expect(result.data.acl.rate_limit).toBeDefined()
|
||||
expect(result.data.acl.rate_limit!.quota).toBe(100)
|
||||
expect(result.data.acl.ip_whitelist).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with only empty ip_whitelist', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with no data', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '\n\n' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - account_access_entries', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts empty account_access_entries (defaults to [])', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.account_access_entries).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid account access entries', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [
|
||||
{ accountId: 1, roleId: 2 },
|
||||
{ accountId: 3, roleId: 4 },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects entry with accountId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 0, roleId: 1 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects entry with roleId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 1, roleId: 0 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - description', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty string description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'A test user account',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects description longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -56,75 +55,9 @@ import { useRoles } from '@/hooks/use-roles'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getCreateUserSchema, getUpdateUserSchema, type UserFormValues } from './schema'
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv4 = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return ipv4.test(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
const accountAccessEntry = (t: any) => z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required'))
|
||||
});
|
||||
|
||||
const baseUserSchema = (t: any) => ({
|
||||
username: z.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z.array(z.number()).min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z.array(accountAccessEntry(t)).optional().default([]),
|
||||
description: z.string().max(256, t('users.actions.schema.description_max')).optional().or(z.literal('')),
|
||||
acl: z.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
}).optional()
|
||||
}).optional().transform((data) => {
|
||||
if (!data) return undefined;
|
||||
const ips = data.ip_whitelist?.split('\n').map(v => v.trim()).filter(Boolean) || [];
|
||||
const finalRateLimit = (data.rate_limit?.quota && data.rate_limit?.interval)
|
||||
? data.rate_limit
|
||||
: undefined;
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined;
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit
|
||||
};
|
||||
})
|
||||
.refine((data) => {
|
||||
if (!data?.ip_whitelist) return true;
|
||||
return data.ip_whitelist.split('\n').every(isValidIP);
|
||||
}, {
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ["ip_whitelist"]
|
||||
})
|
||||
});
|
||||
|
||||
const createUserSchema = (t: any) => z.object({
|
||||
...baseUserSchema(t),
|
||||
password: z.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max')),
|
||||
});
|
||||
|
||||
const updateUserSchema = (t: any) => z.object({
|
||||
...baseUserSchema(t),
|
||||
password: z.string()
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform(v => v || undefined),
|
||||
});
|
||||
|
||||
export type UserForm = z.infer<ReturnType<typeof createUserSchema>> | z.infer<ReturnType<typeof updateUserSchema>>
|
||||
export type UserForm = UserFormValues
|
||||
|
||||
interface Props {
|
||||
currentRow?: User
|
||||
@@ -142,7 +75,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { minimalList: allAccounts } = useMinimalAccountList()
|
||||
|
||||
const form = useForm<UserForm>({
|
||||
resolver: zodResolver(isEdit ? updateUserSchema(t) : createUserSchema(t)),
|
||||
resolver: zodResolver(isEdit ? getUpdateUserSchema(t) : getCreateUserSchema(t)),
|
||||
defaultValues: useMemo(() => {
|
||||
if (isEdit && currentRow) {
|
||||
const accessEntries = currentRow.account_access_map
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const accountAccessEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required')),
|
||||
})
|
||||
|
||||
const isValidIPv4 = (ip: string): boolean => {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
const num = Number(part)
|
||||
return part === String(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return isValidIPv4(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
export const getBaseUserSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z
|
||||
.array(z.number())
|
||||
.min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z
|
||||
.array(accountAccessEntry(t))
|
||||
.optional()
|
||||
.default([]),
|
||||
description: z
|
||||
.string()
|
||||
.max(256, t('users.actions.schema.description_max'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
acl: z
|
||||
.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional()
|
||||
.transform((data) => {
|
||||
if (!data) return undefined
|
||||
const ips =
|
||||
data.ip_whitelist
|
||||
?.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) || []
|
||||
const finalRateLimit =
|
||||
data.rate_limit?.quota && data.rate_limit?.interval
|
||||
? data.rate_limit
|
||||
: undefined
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit,
|
||||
}
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data?.ip_whitelist) return true
|
||||
return data.ip_whitelist.split('\n').every(isValidIP)
|
||||
},
|
||||
{
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ['ip_whitelist'],
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export const getCreateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max')),
|
||||
})
|
||||
|
||||
export const getUpdateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => v || undefined),
|
||||
})
|
||||
|
||||
export type UserFormValues = z.infer<ReturnType<typeof getCreateUserSchema>>
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function AllProviders({ children }: WrapperProps) {
|
||||
const queryClient = createTestQueryClient()
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return render(ui, { wrapper: AllProviders, ...options })
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
export { createTestQueryClient }
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest" />
|
||||
|
||||
+8
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
@@ -16,4 +17,10 @@ export default defineConfig({
|
||||
'@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user