Merge pull request #411 from kimatata/develop
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
This commit is contained in:
1
backend/config/locale.js
Normal file
1
backend/config/locale.js
Normal file
@@ -0,0 +1 @@
|
||||
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja'];
|
||||
@@ -0,0 +1,10 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'locale', {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.removeColumn('users', 'locale');
|
||||
}
|
||||
@@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) {
|
||||
avatarPath: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
locale: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{ underscored: true }
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function (sequelize) {
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
|
||||
46
backend/routes/users/updateLocale.js
Normal file
46
backend/routes/users/updateLocale.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||
const router = express.Router();
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
router.put('/locale', verifySignedIn, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
const { locale } = req.body;
|
||||
|
||||
const normalizedLocale = typeof locale === 'string' ? locale.trim() : '';
|
||||
if (!normalizedLocale || normalizedLocale.length === 0) {
|
||||
return res.status(400).send('Locale is required');
|
||||
}
|
||||
|
||||
if (!SUPPORTED_LOCALES.includes(normalizedLocale)) {
|
||||
return res.status(400).send('Invalid locale');
|
||||
}
|
||||
|
||||
await user.update({ locale: normalizedLocale });
|
||||
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
92
backend/routes/users/updateLocale.test.js
Normal file
92
backend/routes/users/updateLocale.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||
import updateLocaleRoute from './updateLocale.js';
|
||||
|
||||
// mock of authentication middleware
|
||||
let mockUserId = 1;
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
default: () => ({
|
||||
verifySignedIn: vi.fn((req, res, next) => {
|
||||
req.userId = mockUserId; // Mock user ID
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock defineUser
|
||||
const mockUsers = new Map();
|
||||
const mockUser = {
|
||||
findByPk: vi.fn((id) => {
|
||||
const user = mockUsers.get(id);
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
update: vi.fn(async (data) => {
|
||||
Object.assign(user, data);
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../../models/users.js', () => ({
|
||||
default: () => mockUser,
|
||||
}));
|
||||
|
||||
describe('User Locale Routes', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', updateLocaleRoute(sequelize));
|
||||
|
||||
// Reset mock users
|
||||
mockUsers.clear();
|
||||
mockUserId = 1;
|
||||
|
||||
// Create a test user
|
||||
mockUsers.set(1, {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: '',
|
||||
role: 1,
|
||||
avatarPath: null,
|
||||
locale: null,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should update locale', async () => {
|
||||
const newLocale = SUPPORTED_LOCALES[0];
|
||||
const response = await request(app).put('/users/locale').send({ locale: newLocale });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.locale).toBe(newLocale);
|
||||
});
|
||||
|
||||
it('should replace existing locale', async () => {
|
||||
mockUsers.set(1, {
|
||||
locale: SUPPORTED_LOCALES[0],
|
||||
});
|
||||
const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]);
|
||||
});
|
||||
|
||||
it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => {
|
||||
const response = await request(app).put('/users/locale').send({ locale });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export default function (sequelize) {
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
|
||||
@@ -53,6 +53,7 @@ import usersSearchRoute from './routes/users/search.js';
|
||||
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
||||
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
||||
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
||||
import usersUpdateLocaleRoute from './routes/users/updateLocale.js';
|
||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
||||
import signUpRoute from './routes/users/signup.js';
|
||||
@@ -63,6 +64,7 @@ app.use('/users', usersSearchRoute(sequelize));
|
||||
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
||||
app.use('/users', usersUpdateLocaleRoute(sequelize));
|
||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||
app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||
app.use('/users', signUpRoute(sequelize));
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Passwort (Bestätigung)",
|
||||
"invalid_email": "Ungültige E-Mail",
|
||||
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"invalid_locale": "Ungültige Sprache",
|
||||
"username_empty": "Benutzername ist leer",
|
||||
"password_empty": "Passwort ist leer",
|
||||
"password_not_match": "Passwörter stimmen nicht überein",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
"update_password": "Passwort aktualisieren",
|
||||
"password_updated": "Passwort erfolgreich geändert",
|
||||
"change_locale": "Sprache ändern",
|
||||
"update_locale": "Sprache aktualisieren",
|
||||
"locale_updated": "Sprache erfolgreich geändert",
|
||||
"change_avatar": "Avatar ändern",
|
||||
"upload_avatar": "Avatar hochladen",
|
||||
"remove_avatar": "Avatar entfernen",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Password (confirm)",
|
||||
"invalid_email": "Invalid email",
|
||||
"invalid_password": "Password must be at least 8 characters",
|
||||
"invalid_locale": "Invalid language",
|
||||
"username_empty": "username is empty",
|
||||
"password_empty": "password is empty",
|
||||
"password_not_match": "Pasword does not match",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"update_password": "Update Password",
|
||||
"password_updated": "Password updated successfully",
|
||||
"change_locale": "Change Language",
|
||||
"update_locale": "Update Language",
|
||||
"locale_updated": "Language updated successfully",
|
||||
"change_avatar": "Change Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"remove_avatar": "Remove Avatar",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "パスワード(確認)",
|
||||
"invalid_email": "無効なメールアドレスです",
|
||||
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||
"invalid_locale": "無効な言語です",
|
||||
"username_empty": "ユーザー名が入力されていません",
|
||||
"password_empty": "パスワードが入力されていません",
|
||||
"password_not_match": "パスワードが一致しません",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "新しいパスワード(確認)",
|
||||
"update_password": "パスワードを更新",
|
||||
"password_updated": "パスワードが正常に更新されました",
|
||||
"change_locale": "言語の変更",
|
||||
"update_locale": "言語を更新",
|
||||
"locale_updated": "言語が正常に更新されました",
|
||||
"change_avatar": "アバターの変更",
|
||||
"upload_avatar": "アバターをアップロード",
|
||||
"remove_avatar": "アバターを削除",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "Senha (confirmação)",
|
||||
"invalid_email": "E-mail inválido",
|
||||
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
||||
"invalid_locale": "Idioma inválido",
|
||||
"username_empty": "O nome de usuário está vazio",
|
||||
"password_empty": "A senha está vazia",
|
||||
"password_not_match": "A senha não corresponde",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "Confirmar Nova Senha",
|
||||
"update_password": "Atualizar Senha",
|
||||
"password_updated": "Senha atualizada com sucesso",
|
||||
"change_locale": "Alterar Idioma",
|
||||
"update_locale": "Atualizar Idioma",
|
||||
"locale_updated": "Idioma atualizado com sucesso",
|
||||
"change_avatar": "Alterar Avatar",
|
||||
"upload_avatar": "Enviar Avatar",
|
||||
"remove_avatar": "Remover Avatar",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"confirm_password": "确认密码",
|
||||
"invalid_email": "无效的邮箱地址",
|
||||
"invalid_password": "密码必须至少包含 8 个字符",
|
||||
"invalid_locale": "无效的语言",
|
||||
"username_empty": "用户名不能为空",
|
||||
"password_empty": "密码不能为空",
|
||||
"password_not_match": "密码不匹配",
|
||||
@@ -125,6 +126,9 @@
|
||||
"confirm_new_password": "确认新密码",
|
||||
"update_password": "更新密码",
|
||||
"password_updated": "密码更新成功",
|
||||
"change_locale": "修改语言",
|
||||
"update_locale": "更新语言",
|
||||
"locale_updated": "语言更新成功",
|
||||
"change_avatar": "修改头像",
|
||||
"upload_avatar": "上传头像",
|
||||
"remove_avatar": "移除头像",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) {
|
||||
{locales.find((entry) => entry.code === locale)?.name || locale}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="lacales">
|
||||
<DropdownMenu aria-label="locales">
|
||||
{locales.map((entry) => (
|
||||
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
||||
{entry.name}
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
<ThemeSwitch />
|
||||
<div className="hidden md:block">
|
||||
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
||||
<DropdownLanguage locale={locale} onChangeLocale={changeLocale} />
|
||||
{!context.isSignedIn() && <DropdownLanguage locale={locale} onChangeLocale={changeLocale} />}
|
||||
</div>
|
||||
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
||||
</NavbarContent>
|
||||
@@ -224,51 +224,53 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
/>
|
||||
</Listbox>
|
||||
) : (
|
||||
<Listbox
|
||||
aria-label="Account links"
|
||||
itemClasses={{
|
||||
base: 'h-10 text-large',
|
||||
}}
|
||||
>
|
||||
<ListboxItem
|
||||
key="signin"
|
||||
startContent={<ArrowRightToLine size={16} />}
|
||||
title={messages.signIn}
|
||||
onPress={() => {
|
||||
router.push('/account/signin', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
<>
|
||||
<Listbox
|
||||
aria-label="Account links"
|
||||
itemClasses={{
|
||||
base: 'h-10 text-large',
|
||||
}}
|
||||
/>
|
||||
<ListboxItem
|
||||
key="signup"
|
||||
title={messages.signUp}
|
||||
startContent={<PenTool size={16} />}
|
||||
onPress={() => {
|
||||
router.push('/account/signup', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
>
|
||||
<ListboxItem
|
||||
key="signin"
|
||||
startContent={<ArrowRightToLine size={16} />}
|
||||
title={messages.signIn}
|
||||
onPress={() => {
|
||||
router.push('/account/signin', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ListboxItem
|
||||
key="signup"
|
||||
title={messages.signUp}
|
||||
startContent={<PenTool size={16} />}
|
||||
onPress={() => {
|
||||
router.push('/account/signup', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Listbox>
|
||||
<p className="font-bold">{messages.languages}</p>
|
||||
<Listbox
|
||||
aria-label="Language links"
|
||||
itemClasses={{
|
||||
base: 'h-10 text-large',
|
||||
}}
|
||||
/>
|
||||
</Listbox>
|
||||
>
|
||||
{locales.map((entry) => (
|
||||
<ListboxItem
|
||||
key={entry.code}
|
||||
startContent={<Globe size={16} />}
|
||||
title={entry.name}
|
||||
onPress={() => {
|
||||
changeLocale(entry.code);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
<p className="font-bold">{messages.languages}</p>
|
||||
<Listbox
|
||||
aria-label="Language links"
|
||||
itemClasses={{
|
||||
base: 'h-10 text-large',
|
||||
}}
|
||||
>
|
||||
{locales.map((entry) => (
|
||||
<ListboxItem
|
||||
key={entry.code}
|
||||
startContent={<Globe size={16} />}
|
||||
title={entry.name}
|
||||
onPress={() => {
|
||||
changeLocale(entry.code);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
</NavbarMenu>
|
||||
</Navbar>
|
||||
|
||||
@@ -60,6 +60,7 @@ async function signInAsGuest() {
|
||||
username: 'Guest',
|
||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||
avatarPath: '',
|
||||
locale: null,
|
||||
};
|
||||
const token = await signUp(guestUser);
|
||||
return token;
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
||||
username: '',
|
||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||
avatarPath: '',
|
||||
locale: null,
|
||||
});
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
||||
|
||||
context.setToken(token);
|
||||
context.storeTokenToLocalStorage(token);
|
||||
router.push('/account', { locale: locale });
|
||||
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||
};
|
||||
|
||||
const handleSignInAsGuest = async () => {
|
||||
const token = await signInAsGuest();
|
||||
context.setToken(token);
|
||||
context.storeTokenToLocalStorage(token);
|
||||
router.push('/account', { locale: locale });
|
||||
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
import { useState, useContext, useRef } from 'react';
|
||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
|
||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
|
||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { useRouter, usePathname } from '@/src/i18n/routing';
|
||||
import { locales } from '@/config/selection';
|
||||
import { LocaleType } from '@/types/locale';
|
||||
|
||||
type ProfileSettingsPageMessages = {
|
||||
profileSettings: string;
|
||||
@@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = {
|
||||
confirmNewPassword: string;
|
||||
updatePassword: string;
|
||||
passwordUpdated: string;
|
||||
changeLocale: string;
|
||||
updateLocale: string;
|
||||
localeUpdated: string;
|
||||
changeAvatar: string;
|
||||
uploadAvatar: string;
|
||||
removeAvatar: string;
|
||||
@@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = {
|
||||
invalidPassword: string;
|
||||
passwordNotMatch: string;
|
||||
usernameEmpty: string;
|
||||
invalidLocale: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -38,15 +46,21 @@ type Props = {
|
||||
locale: LocaleCodeType;
|
||||
};
|
||||
|
||||
export default function ProfileSettingsPage({ messages }: Props) {
|
||||
export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [locale, setLocale] = useState<LocaleCodeType>(context.token?.user?.locale ?? defaultLocale);
|
||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||
const [isUpdatingLocale, setIsUpdatingLocale] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
const handleUsernameUpdate = async () => {
|
||||
@@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocaleUpdate = async () => {
|
||||
if (!locales.some((l) => l.code === locale)) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.invalidLocale,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingLocale(true);
|
||||
try {
|
||||
const result = await updateLocale(context.token.access_token, locale);
|
||||
if (result && result.user) {
|
||||
// refresh locale
|
||||
const newToken = { ...context.token };
|
||||
if (newToken.user) {
|
||||
newToken.user.locale = result.user.locale;
|
||||
}
|
||||
context.setToken(newToken);
|
||||
context.storeTokenToLocalStorage(newToken);
|
||||
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.localeUpdated,
|
||||
});
|
||||
const nextLocale = result.user.locale ?? locale;
|
||||
setLocale(nextLocale);
|
||||
changeLocale(nextLocale);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error updating locale:', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdatingLocale(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function changeLocale(nextLocale: LocaleCodeType) {
|
||||
router.push(pathname, { locale: nextLocale });
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Locale */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<Globe size={16} />
|
||||
<h2 className="text-large font-semibold ml-2">{messages.changeLocale}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
<Select<LocaleType>
|
||||
fullWidth
|
||||
aria-label="change locale"
|
||||
selectedKeys={[locale]}
|
||||
disabledKeys={[locale]}
|
||||
onSelectionChange={(value) => {
|
||||
const selectedLocale = locales.find((locale) => locale.code === value.currentKey);
|
||||
if (!selectedLocale) return;
|
||||
setLocale(selectedLocale.code);
|
||||
}}
|
||||
>
|
||||
{locales.map((locale) => (
|
||||
<SelectItem key={locale.code}>{locale.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleLocaleUpdate}
|
||||
isLoading={isUpdatingLocale}
|
||||
isDisabled={locale === context.token?.user?.locale}
|
||||
size="sm"
|
||||
>
|
||||
{messages.updateLocale}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Avatar */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default function Page({ params }: PageType) {
|
||||
confirmNewPassword: t('confirm_new_password'),
|
||||
updatePassword: t('update_password'),
|
||||
passwordUpdated: t('password_updated'),
|
||||
changeLocale: t('change_locale'),
|
||||
updateLocale: t('update_locale'),
|
||||
localeUpdated: t('locale_updated'),
|
||||
changeAvatar: t('change_avatar'),
|
||||
uploadAvatar: t('upload_avatar'),
|
||||
removeAvatar: t('remove_avatar'),
|
||||
@@ -29,6 +32,7 @@ export default function Page({ params }: PageType) {
|
||||
invalidPassword: t('invalid_password'),
|
||||
passwordNotMatch: t('password_not_match'),
|
||||
usernameEmpty: t('username_empty'),
|
||||
invalidLocale: t('invalid_locale'),
|
||||
};
|
||||
|
||||
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
|
||||
<TableBody>
|
||||
<TableRow key="1">
|
||||
<TableCell>{messages.unittcms_version}</TableCell>
|
||||
<TableCell>1.0.0-beta.27</TableCell>
|
||||
<TableCell>1.0.0-beta.28</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="2">
|
||||
<TableCell>{messages.api_server}</TableCell>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function CaseEditor({
|
||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||
const [isTitleInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [plusCount, setPlusCount] = useState<number>(0);
|
||||
const [idCounter, setIdCounter] = useState<number>(0);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
@@ -68,9 +68,14 @@ export default function CaseEditor({
|
||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||
|
||||
const onPlusClick = async (newStepNo: number) => {
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
setIsDirty(true);
|
||||
const nextId = idCounter + 1;
|
||||
const newStep: StepType = {
|
||||
id: plusCount,
|
||||
// hypothetical ID
|
||||
id: nextId,
|
||||
step: '',
|
||||
result: '',
|
||||
createdAt: new Date(),
|
||||
@@ -78,66 +83,65 @@ export default function CaseEditor({
|
||||
caseSteps: {
|
||||
stepNo: newStepNo,
|
||||
},
|
||||
uid: `uid${plusCount}`,
|
||||
uid: `uid${nextId}`,
|
||||
editState: 'new',
|
||||
};
|
||||
setPlusCount(plusCount + 1);
|
||||
|
||||
if (testCase.Steps) {
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo >= newStepNo) {
|
||||
return {
|
||||
...step,
|
||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo >= newStepNo) {
|
||||
return {
|
||||
...step,
|
||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
updatedSteps.push(newStep);
|
||||
updatedSteps.push(newStep);
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
}
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
setIdCounter(nextId);
|
||||
};
|
||||
|
||||
const onDeleteClick = async (stepId: number) => {
|
||||
setIsDirty(true);
|
||||
|
||||
// find deletedStep's stepNo
|
||||
if (testCase.Steps) {
|
||||
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
||||
if (!deletedStep) {
|
||||
return;
|
||||
}
|
||||
const deletedStepNo = deletedStep.caseSteps.stepNo;
|
||||
deletedStep.editState = 'deleted';
|
||||
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo > deletedStepNo) {
|
||||
return {
|
||||
...step,
|
||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
// find deletedStep's stepNo
|
||||
|
||||
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
||||
if (!deletedStep) {
|
||||
return;
|
||||
}
|
||||
const deletedStepNo = deletedStep.caseSteps.stepNo;
|
||||
deletedStep.editState = 'deleted';
|
||||
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo > deletedStepNo) {
|
||||
return {
|
||||
...step,
|
||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLElement>) => {
|
||||
@@ -201,18 +205,20 @@ export default function CaseEditor({
|
||||
changeStep.editState = 'changed';
|
||||
}
|
||||
|
||||
if (testCase.Steps) {
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: testCase.Steps.map((step) => {
|
||||
if (step.id === stepId) {
|
||||
return changeStep;
|
||||
} else {
|
||||
return step;
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (!testCase.Steps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: testCase.Steps.map((step) => {
|
||||
if (step.id === stepId) {
|
||||
return changeStep;
|
||||
} else {
|
||||
return step;
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,6 +229,11 @@ export default function CaseEditor({
|
||||
data.Steps.forEach((step: StepType) => {
|
||||
step.editState = 'notChanged';
|
||||
});
|
||||
|
||||
// set idCounter to the max step id to avoid id conflict for new steps
|
||||
// id is not reflected on database
|
||||
const maxStepId = data.Steps.reduce((maxId: number, step: StepType) => Math.max(maxId, step.id), 0);
|
||||
setIdCounter(maxStepId);
|
||||
setTestCase(data);
|
||||
if (data.Tags) {
|
||||
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
||||
avatarPath: '',
|
||||
role: -1,
|
||||
username: '',
|
||||
locale: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type UserType = {
|
||||
username: string;
|
||||
role: number;
|
||||
avatarPath: string | null;
|
||||
locale: LocaleCodeType | null;
|
||||
};
|
||||
|
||||
export type TokenProps = {
|
||||
|
||||
103
frontend/utils/formGuard.test.ts
Normal file
103
frontend/utils/formGuard.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// mock cleanup
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
vi.mock('react', () => ({
|
||||
useEffect: (fn: () => void) => {
|
||||
cleanupFn = fn() as (() => void) | undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useFormGuard } from './formGuard';
|
||||
|
||||
describe('useFormGuard', () => {
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - we will mock confirm
|
||||
confirmSpy = vi.spyOn(window, 'confirm');
|
||||
cleanupFn = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupFn?.();
|
||||
cleanupFn = undefined;
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
// helper: simulate clicking an anchor
|
||||
const clickAnchor = (href: string, target?: string): MouseEvent => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.setAttribute('href', href);
|
||||
if (target) anchor.setAttribute('target', target);
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
anchor.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('does not show confirm when form is clean', () => {
|
||||
useFormGuard(false, 'Leave?');
|
||||
clickAnchor('/some-path');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm with the given text when dirty', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Are you sure?');
|
||||
clickAnchor('/other-page');
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
|
||||
});
|
||||
|
||||
it('prevents navigation when dirty and user cancels confirm', () => {
|
||||
confirmSpy.mockReturnValue(false);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('allows navigation when dirty and user confirms', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external links', () => {
|
||||
it('does not show confirm for anchor with target="_blank"', () => {
|
||||
useFormGuard(true, 'Leave?');
|
||||
clickAnchor('https://example.com', '_blank');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitTCMS use case', () => {
|
||||
it('does not show confirm when navigating to case detail page of test run', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm when navigating to test cases page', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases`);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
|
||||
const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => {
|
||||
return compiledPatterns.some((regex) => regex.test(href));
|
||||
};
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => {
|
||||
useEffect(() => {
|
||||
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
|
||||
try {
|
||||
return [new RegExp(pattern)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty) return;
|
||||
|
||||
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
if (!href) return;
|
||||
|
||||
// do not show confirm for ignored paths
|
||||
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
|
||||
if (isIgnoredPath(href, compiledPatterns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [confirmText, isDirty, ignorePaths]);
|
||||
}, [confirmText, isDirty, ignorePathPatterns]);
|
||||
};
|
||||
|
||||
@@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLocale(jwt: string, locale: string) {
|
||||
const updateData = {
|
||||
locale,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/locale`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating locale:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
findUser,
|
||||
searchUsers,
|
||||
@@ -228,4 +258,5 @@ export {
|
||||
uploadAvatar,
|
||||
deleteAvatar,
|
||||
adminResetPassword,
|
||||
updateLocale,
|
||||
};
|
||||
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -19,7 +19,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react": "^19.2.4",
|
||||
"sequelize": "^6.37.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"supertest": "^7.1.4",
|
||||
@@ -50,10 +52,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1255,6 +1258,23 @@
|
||||
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
|
||||
@@ -1289,6 +1309,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
||||
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.29.1",
|
||||
"@typescript-eslint/types": "8.29.1",
|
||||
@@ -1838,6 +1859,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2938,6 +2960,19 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -3182,6 +3217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3361,6 +3397,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
|
||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
@@ -4220,6 +4257,25 @@
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.8.3",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
|
||||
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -6349,6 +6405,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -7498,6 +7564,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7974,6 +8041,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
|
||||
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "1.6.1",
|
||||
"@vitest/runner": "1.6.1",
|
||||
@@ -8034,6 +8102,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -8184,6 +8262,28 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react": "^19.2.4",
|
||||
"sequelize": "^6.37.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"supertest": "^7.1.4",
|
||||
|
||||
Reference in New Issue
Block a user