Merge pull request #319 from kimatata/develop

This commit is contained in:
kimatata
2025-10-26 19:59:45 +09:00
committed by GitHub
34 changed files with 1152 additions and 128 deletions

View File

@@ -37,6 +37,7 @@ export default function (sequelize) {
const cases = caseRecords.map((c) => c.get({ plain: true }));
const clonedCases = cases.map((c) => {
// eslint-disable-next-line no-unused-vars
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c;
return { ...clonedCase, folderId: targetFolderId };
});
@@ -47,6 +48,7 @@ export default function (sequelize) {
if (c.Steps) {
const clonedSteps = c.Steps.map((s) => {
// eslint-disable-next-line no-unused-vars
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s;
return clonedStep;
});

View File

@@ -54,6 +54,7 @@ export default function (sequelize) {
const cases = folderCases.map((c) => c.get({ plain: true }));
const clonedCases = cases.map((c) => {
// eslint-disable-next-line no-unused-vars
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c;
return { ...clonedCase, folderId: targetFolderId };
});
@@ -63,6 +64,7 @@ export default function (sequelize) {
if (c.Steps && c.Steps.length > 0) {
const clonedSteps = c.Steps.map((s) => {
// eslint-disable-next-line no-unused-vars
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s;
return clonedStep;
});

View File

@@ -0,0 +1,140 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import multer from 'multer';
import express from 'express';
import { DataTypes } from 'sequelize';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
const router = express.Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const User = defineUser(sequelize, DataTypes);
// Create avatars folder if it does not exist
const avatarDir = path.join(__dirname, '../../public/uploads/avatars');
if (!fs.existsSync(avatarDir)) {
fs.mkdirSync(avatarDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, avatarDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const fileName = `avatar_${req.userId}_${Date.now()}${ext}`;
cb(null, fileName);
},
});
const fileFilter = (req, file, cb) => {
// Accept images only
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
});
// Upload avatar
router.post('/avatar', verifySignedIn, upload.single('avatar'), async (req, res) => {
try {
const userId = req.userId;
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const user = await User.findByPk(userId);
if (!user) {
// Delete uploaded file if user not found
fs.unlinkSync(file.path);
return res.status(404).send('User not found');
}
// Delete old avatar if exists
if (user.avatarPath) {
// Validate that avatarPath is within expected directory
const oldAvatarPath = path.join(__dirname, '../../public', user.avatarPath);
const avatarDirResolved = path.resolve(__dirname, '../../public/uploads/avatars');
const oldAvatarResolved = path.resolve(oldAvatarPath);
// Ensure the path is within the avatars directory (prevent path traversal)
if (oldAvatarResolved.startsWith(avatarDirResolved) && fs.existsSync(oldAvatarPath)) {
fs.unlinkSync(oldAvatarPath);
}
}
// Update user with new avatar path
const avatarPath = `/uploads/avatars/${file.filename}`;
await user.update({ avatarPath });
// Return updated user without password
const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
});
res.json({ user: updatedUser });
} catch (error) {
console.error(error);
// Clean up uploaded file on error
if (req.file) {
fs.unlinkSync(req.file.path);
}
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete avatar
router.delete('/avatar', 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');
}
// Delete avatar file if exists
if (user.avatarPath) {
// Validate that avatarPath is within expected directory
const avatarPath = path.join(__dirname, '../../public', user.avatarPath);
const avatarDirResolved = path.resolve(__dirname, '../../public/uploads/avatars');
const avatarPathResolved = path.resolve(avatarPath);
// Ensure the path is within the avatars directory (prevent path traversal)
if (avatarPathResolved.startsWith(avatarDirResolved) && fs.existsSync(avatarPath)) {
fs.unlinkSync(avatarPath);
}
}
// Update user to remove avatar path
await user.update({ avatarPath: null });
// Return updated user without password
const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
});
res.json({ user: updatedUser });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}

View File

@@ -0,0 +1,49 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { DataTypes } from 'sequelize';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
const router = express.Router();
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const User = defineUser(sequelize, DataTypes);
// Change user password
router.put('/password', verifySignedIn, async (req, res) => {
try {
const userId = req.userId;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).send('Current password and new password are required');
}
if (newPassword.length < 8) {
return res.status(400).send('New password must be at least 8 characters');
}
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).send('User not found');
}
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, user.password);
if (!passwordMatch) {
return res.status(401).send('Current password is incorrect');
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
await user.update({ password: hashedPassword });
res.json({ message: 'Password updated successfully' });
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { Sequelize } from 'sequelize';
import updatePasswordRoute from './updatePassword.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,
}));
// mock bcrypt (just add 'hashed_' prefix)
vi.mock('bcrypt', () => ({
default: {
hashSync: (pw) => `hashed_${pw}`,
compareSync: (pw, hashed) => hashed === `hashed_${pw}`,
hash: async (pw) => `hashed_${pw}`,
compare: async (pw, hashed) => hashed === `hashed_${pw}`,
},
}));
describe('User Profile Routes', () => {
let app;
const sequelize = new Sequelize({
dialect: 'sqlite',
logging: false,
});
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/users', updatePasswordRoute(sequelize));
// Reset mock users
mockUsers.clear();
mockUserId = 1;
// Create a test user
mockUsers.set(1, {
id: 1,
email: 'test@example.com',
username: 'testuser',
password: 'hashed_testpassword123',
role: 1,
avatarPath: null,
});
vi.clearAllMocks();
});
it('should update password', async () => {
const newPassword = 'newpassword123';
const response = await request(app).put('/users/password').send({
currentPassword: 'testpassword123',
newPassword: newPassword,
});
expect(response.status).toBe(200);
expect(response.body.message).toContain('successfully');
});
it('should reject incorrect current password', async () => {
const response = await request(app).put('/users/password').send({
currentPassword: 'wrongpassword',
newPassword: 'newpassword456',
});
expect(response.status).toBe(401);
});
it('should reject password shorter than 8 characters', async () => {
const response = await request(app).put('/users/password').send({
currentPassword: 'testpassword123',
newPassword: 'short',
});
expect(response.status).toBe(400);
});
it('should reject missing current password', async () => {
const response = await request(app).put('/users/password').send({
newPassword: 'newpassword123',
});
expect(response.status).toBe(400);
});
});

View File

@@ -9,7 +9,7 @@ export default function (sequelize) {
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
const User = defineUser(sequelize, DataTypes);
router.put('/:userId', verifySignedIn, verifyAdmin, async (req, res) => {
router.put('/:userId/role', verifySignedIn, verifyAdmin, async (req, res) => {
// param check
const userId = req.params.userId;
if (!userId) {

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { Sequelize } from 'sequelize';
import updateRoute from './update';
import updateRoleRoute from './updateRole';
import { roles } from './authSettings.js';
const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator');
@@ -44,11 +44,11 @@ describe('updateUserRole', () => {
app.use(express.json());
// Mount the update route
app.use('/users', updateRoute(sequelize));
app.use('/users', updateRoleRoute(sequelize));
});
it('call update API without new role', async () => {
const response = await request(app).put('/users/2').send();
const response = await request(app).put('/users/2/role').send();
expect(response.status).toBe(400);
expect(response.text).toBe('newRole is required');
@@ -56,7 +56,7 @@ describe('updateUserRole', () => {
it('promote not existing user to admin will return 404', async () => {
mockUser.findByPk.mockResolvedValue(null); // No user found
const response = await request(app).put('/users/2').send({
const response = await request(app).put('/users/2/role').send({
newRole: 0,
});
@@ -68,7 +68,7 @@ describe('updateUserRole', () => {
const targetUser = { id: 2, role: userRoleIndex, update: vi.fn() }; // Normal user
mockUser.findByPk.mockResolvedValue(targetUser);
const response = await request(app).put('/users/2').send({
const response = await request(app).put('/users/2/role').send({
newRole: 0,
});
@@ -81,7 +81,7 @@ describe('updateUserRole', () => {
mockUser.findByPk.mockResolvedValue(targetUser);
mockUser.count.mockResolvedValue(1); // Only one admin
const response = await request(app).put('/users/1').send({
const response = await request(app).put('/users/1/role').send({
newRole: 1,
}); // Downgrading admin to user
@@ -96,7 +96,7 @@ describe('updateUserRole', () => {
// Simulate DB error
mockUser.findByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app).put('/users/1').send({
const response = await request(app).put('/users/1/role').send({
newRole: 0,
});

View File

@@ -0,0 +1,40 @@
import express from 'express';
import { DataTypes } from 'sequelize';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
const router = express.Router();
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const User = defineUser(sequelize, DataTypes);
router.put('/username', verifySignedIn, async (req, res) => {
try {
const userId = req.userId;
const { username } = req.body;
if (!username || username.trim().length === 0) {
return res.status(400).send('Username is required');
}
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).send('User not found');
}
await user.update({ username: username.trim() });
// Return updated user without password
const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
});
res.json({ user: updatedUser });
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { Sequelize } from 'sequelize';
import updateUsernameRoute from './updateUsername.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 Profile Routes', () => {
let app;
const sequelize = new Sequelize({
dialect: 'sqlite',
logging: false,
});
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/users', updateUsernameRoute(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,
});
vi.clearAllMocks();
});
it('should update username', async () => {
const newUsername = 'updatedusername';
const response = await request(app).put('/users/username').send({ username: newUsername });
expect(response.status).toBe(200);
expect(response.body.user.username).toBe(newUsername);
});
it('should reject empty username', async () => {
const response = await request(app).put('/users/username').send({ username: '' });
expect(response.status).toBe(400);
});
it('should reject whitespace-only username', async () => {
const response = await request(app).put('/users/username').send({ username: ' ' });
expect(response.status).toBe(400);
});
});

View File

@@ -50,13 +50,19 @@ app.use('/health', healthIndexRoute());
import usersIndexRoute from './routes/users/index.js';
import usersFindRoute from './routes/users/find.js';
import usersSearchRoute from './routes/users/search.js';
import usersUpdateRoute from './routes/users/update.js';
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
import usersUpdateRoleRoute from './routes/users/updateRole.js';
import signUpRoute from './routes/users/signup.js';
import signInRoute from './routes/users/signin.js';
app.use('/users', usersIndexRoute(sequelize));
app.use('/users', usersFindRoute(sequelize));
app.use('/users', usersSearchRoute(sequelize));
app.use('/users', usersUpdateRoute(sequelize));
app.use('/users', usersUpdateUsernameRoute(sequelize));
app.use('/users', usersUpdatePasswordRoute(sequelize));
app.use('/users', usersUpdateAvatarRoute(sequelize));
app.use('/users', usersUpdateRoleRoute(sequelize));
app.use('/users', signUpRoute(sequelize));
app.use('/users', signInRoute(sequelize));

View File

@@ -22,8 +22,8 @@ erDiagram
cases ||--o{ caseSteps: "has"
cases ||--o{ caseAttachments: "has"
attachments ||--o{ caseAttachments: "has"
cases ||--o{ "caseTags(unimplemented)": "has"
"tags(unimplemented)" ||--o{ "caseTags(unimplemented)": "has"
cases ||--o{ "caseTags": "has"
"tags" ||--o{ "caseTags": "has"
users {
integer id PK
@@ -139,7 +139,7 @@ erDiagram
integer attachmentId FK
}
"tags(unimplemented)" {
"tags" {
integer id PK
string name
integer projectId FK
@@ -147,7 +147,7 @@ erDiagram
timestamp deleted_at
}
"caseTags(unimplemented)" {
"caseTags" {
integer id PK
integer caseId FK
integer tagId FK

View File

@@ -4,7 +4,7 @@ sidebar_position: 1
# Self hosting
UnitTCMS is designed for self-hosted use. There are two ways to self-host the application: “Docker” and “Manual”.
UnitTCMS is designed for self-hosted use. There are two ways to self-host the application: “Docker” and “From Source”.
- Docker (Recommended)
- From Source

View File

@@ -5,8 +5,6 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginReact from 'eslint-plugin-react';
import eslintPluginReactHooks from 'eslint-plugin-react-hooks';
import * as eslintPluginImport from 'eslint-plugin-import';
import eslintPluginUnusedImports from 'eslint-plugin-unused-imports';
import eslintPluginOnlyWarn from 'eslint-plugin-only-warn';
import eslintPluginNext from '@next/eslint-plugin-next';
export default tseslint.config(
@@ -25,11 +23,9 @@ export default tseslint.config(
},
plugins: {
import: eslintPluginImport,
'unused-imports': eslintPluginUnusedImports,
react: eslintPluginReact,
'react-hooks': eslintPluginReactHooks,
'@next/next': eslintPluginNext,
'only-warn': eslintPluginOnlyWarn,
},
settings: {
react: {
@@ -46,16 +42,7 @@ export default tseslint.config(
extends: [eslint.configs.recommended],
rules: {
'import/order': 'error',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
'no-unused-vars': 'error',
},
},
{
@@ -67,7 +54,6 @@ export default tseslint.config(
eslintPluginReact.configs.flat['jsx-runtime'],
],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-namespace': 'off',
'react/prop-types': 'off',
...eslintPluginReactHooks.configs.recommended.rules,

View File

@@ -1,20 +1,30 @@
import { Avatar as HeroUiAvatar } from '@heroui/react';
import { User } from 'lucide-react';
import Avatar from 'boring-avatars';
import { TokenContextType } from '@/types/user';
import Config from '@/config/config';
const apiServer = Config.apiServer;
type Props = {
context: TokenContextType;
size: number;
username: string | undefined | null;
avatarPath?: string | undefined | null;
};
export default function PublicityChip({ context }: Props) {
return context.isSignedIn() ? (
<Avatar
size={16}
name={context.token?.user?.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
) : (
<User size={16} />
);
export default function UserAvatar({ size, username, avatarPath }: Props) {
if (username) {
if (avatarPath) {
return <HeroUiAvatar style={{ width: size, height: size }} src={`${apiServer}${avatarPath}`} />;
} else {
return (
<Avatar
size={size}
name={username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
);
}
} else {
return <User size={size} />;
}
}

View File

@@ -77,6 +77,7 @@
"projects": "Projects",
"admin": "Administration",
"account": "Account",
"profile_settings": "Profile Settings",
"signup": "Sign up",
"signin": "Sign in",
"signout": "Sign out",
@@ -112,7 +113,27 @@
"public": "Public",
"private": "Private",
"not_own_any_projects": "You don't own any projects.",
"find_projects": "Find projects"
"find_projects": "Find projects",
"profile_settings": "Profile Settings",
"change_username": "Change Username",
"new_username": "New Username",
"update_username": "Update Username",
"username_updated": "Username updated successfully",
"change_password": "Change Password",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_new_password": "Confirm New Password",
"update_password": "Update Password",
"password_updated": "Password updated successfully",
"change_avatar": "Change Avatar",
"upload_avatar": "Upload Avatar",
"remove_avatar": "Remove Avatar",
"avatar_updated": "Avatar updated successfully",
"avatar_removed": "Avatar removed successfully",
"max_file_size_5mb": "Max. file size: 5MB",
"only_images_allowed": "Only image files are allowed",
"current_password_incorrect": "Current password is incorrect",
"update_error": "Update failed"
},
"Health": {
"health_check": "Health Check",

View File

@@ -78,6 +78,7 @@
"projects": "プロジェクト",
"admin": "管理",
"account": "アカウント",
"profile_settings": "プロフィール設定",
"signup": "新規登録",
"signin": "サインイン",
"signout": "サインアウト",
@@ -113,7 +114,27 @@
"public": "パブリック",
"private": "プライベート",
"not_own_any_projects": "所有しているプロジェクトがありません。",
"find_projects": "プロジェクトを探す"
"find_projects": "プロジェクトを探す",
"profile_settings": "プロフィール設定",
"change_username": "ユーザー名の変更",
"new_username": "新しいユーザー名",
"update_username": "ユーザー名を更新",
"username_updated": "ユーザー名が正常に更新されました",
"change_password": "パスワードの変更",
"current_password": "現在のパスワード",
"new_password": "新しいパスワード",
"confirm_new_password": "新しいパスワード(確認)",
"update_password": "パスワードを更新",
"password_updated": "パスワードが正常に更新されました",
"change_avatar": "アバターの変更",
"upload_avatar": "アバターをアップロード",
"remove_avatar": "アバターを削除",
"avatar_updated": "アバターが正常に更新されました",
"avatar_removed": "アバターが正常に削除されました",
"max_file_size_5mb": "最大ファイルサイズ5MB",
"only_images_allowed": "画像ファイルのみ許可されています",
"current_password_incorrect": "現在のパスワードが正しくありません",
"update_error": "更新に失敗しました"
},
"Admin": {
"user_management": "ユーザー管理",

View File

@@ -77,6 +77,7 @@
"projects": "Projetos",
"admin": "Administração",
"account": "Conta",
"profile_settings": "Configurações do Perfil",
"signup": "Cadastre-se",
"signin": "Entrar",
"signout": "Sair",
@@ -112,7 +113,27 @@
"public": "Público",
"private": "Privado",
"not_own_any_projects": "Você não possui nenhum projeto.",
"find_projects": "Encontrar projetos"
"find_projects": "Encontrar projetos",
"profile_settings": "Configurações do Perfil",
"change_username": "Alterar Nome de Usuário",
"new_username": "Novo Nome de Usuário",
"update_username": "Atualizar Nome de Usuário",
"username_updated": "Nome de usuário atualizado com sucesso",
"change_password": "Alterar Senha",
"current_password": "Senha Atual",
"new_password": "Nova Senha",
"confirm_new_password": "Confirmar Nova Senha",
"update_password": "Atualizar Senha",
"password_updated": "Senha atualizada com sucesso",
"change_avatar": "Alterar Avatar",
"upload_avatar": "Enviar Avatar",
"remove_avatar": "Remover Avatar",
"avatar_updated": "Avatar atualizado com sucesso",
"avatar_removed": "Avatar removido com sucesso",
"max_file_size_5mb": "Tamanho máximo do arquivo: 5MB",
"only_images_allowed": "Apenas arquivos de imagem são permitidos",
"current_password_incorrect": "A senha atual está incorreta",
"update_error": "Falha na atualização"
},
"Health": {
"health_check": "Verificação de Saúde",

View File

@@ -1,6 +1,6 @@
'use client';
import { Button, DropdownTrigger, Dropdown, DropdownMenu, DropdownItem } from '@heroui/react';
import { ChevronDown, PenTool, ArrowRightFromLine, ArrowRightToLine } from 'lucide-react';
import { ChevronDown, PenTool, ArrowRightFromLine, ArrowRightToLine, Settings } from 'lucide-react';
import { useContext } from 'react';
import { TokenContext } from '@/utils/TokenProvider';
import { useRouter } from '@/src/i18n/routing';
@@ -31,12 +31,23 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
{
uid: 'account',
title: messages.account,
icon: <UserAvatar context={context} />,
icon: (
<UserAvatar size={16} username={context.token?.user?.username} avatarPath={context.token?.user?.avatarPath} />
),
onPress: () => {
router.push('/account', { locale: locale });
onItemPress();
},
},
{
uid: 'profile',
title: messages.profileSettings,
icon: <Settings size={16} />,
onPress: () => {
router.push('/account/settings', { locale: locale });
onItemPress();
},
},
{
uid: 'signout',
title: messages.signOut,
@@ -75,7 +86,13 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
<Button
size="sm"
variant="light"
startContent={<UserAvatar context={context} />}
startContent={
<UserAvatar
size={16}
username={context.token?.user?.username}
avatarPath={context.token?.user?.avatarPath}
/>
}
endContent={<ChevronDown size={16} />}
>
{context.isSignedIn() ? context.token?.user?.username : messages.signIn}

View File

@@ -10,6 +10,7 @@ export default function Header(params: { locale: LocaleCodeType }) {
docs: t('docs'),
roadmap: t('roadmap'),
account: t('account'),
profileSettings: t('profile_settings'),
signUp: t('signup'),
signIn: t('signin'),
signOut: t('signout'),

View File

@@ -13,7 +13,7 @@ import {
ListboxItem,
Listbox,
} from '@heroui/react';
import { ArrowRightFromLine, ArrowRightToLine, File, Globe, MoveUpRight, PenTool } from 'lucide-react';
import { ArrowRightFromLine, ArrowRightToLine, File, Globe, MoveUpRight, PenTool, Settings } from 'lucide-react';
import DropdownAccount from './DropdownAccount';
import DropdownLanguage from './DropdownLanguage';
import { ThemeSwitch } from '@/components/ThemeSwitch';
@@ -31,6 +31,7 @@ type NabbarMenuMessages = {
docs: string;
roadmap: string;
account: string;
profileSettings: string;
signUp: string;
signIn: string;
signOut: string;
@@ -185,12 +186,27 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
<ListboxItem
key="account"
title={messages.account}
startContent={<UserAvatar context={context} />}
startContent={
<UserAvatar
size={16}
username={context.token?.user?.username}
avatarPath={context.token?.user?.avatarPath}
/>
}
onPress={() => {
router.push('/account', { locale: locale });
setIsMenuOpen(false);
}}
/>
<ListboxItem
key="profile"
title={messages.profileSettings}
startContent={<Settings size={16} />}
onPress={() => {
router.push('/account/settings', { locale: locale });
setIsMenuOpen(false);
}}
/>
<ListboxItem
key="signout"
title={messages.signOut}

View File

@@ -1,13 +1,13 @@
'use client';
import { useState, useEffect, useContext } from 'react';
import { Button, Card, CardHeader, CardFooter } from '@heroui/react';
import Avatar from 'boring-avatars';
import { ArrowRight } from 'lucide-react';
import { ArrowRight, Settings } from 'lucide-react';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchMyProjects } from '@/utils/projectsControl';
import { ProjectType } from '@/types/project';
import PublicityChip from '@/components/PublicityChip';
import UserAvatar from '@/components/UserAvatar';
import { LocaleCodeType } from '@/types/locale';
import { logError } from '@/utils/errorHandler';
@@ -17,6 +17,7 @@ type AccountPageMessages = {
private: string;
notOwnAnyProjects: string;
findProjects: string;
profileSettings: string;
};
type Props = {
@@ -51,17 +52,28 @@ export default function AccountPage({ messages, locale }: Props) {
<div className="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
<div className="w-full p-3 flex items-center justify-between">
<Card className="w-[600px]">
<CardHeader className="flex gap-6">
<Avatar
size={48}
name={context.token?.user?.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
<div className="flex flex-col">
<p className="text-xl font-bold">{context.token?.user?.username}</p>
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
<CardHeader className="flex gap-6 justify-between">
<div className="flex gap-6">
<UserAvatar
size={48}
username={context.token?.user?.username}
avatarPath={context.token?.user?.avatarPath}
/>
<div className="flex flex-col">
<p className="text-xl font-bold">{context.token?.user?.username}</p>
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
</div>
</div>
<Button
as={Link}
href="/account/settings"
locale={locale}
variant="flat"
size="sm"
startContent={<Settings size={16} />}
>
{messages.profileSettings}
</Button>
</CardHeader>
</Card>
</div>

View File

@@ -11,6 +11,7 @@ export default function Page({ params }: PageType) {
private: t('private'),
notOwnAnyProjects: t('not_own_any_projects'),
findProjects: t('find_projects'),
profileSettings: t('profile_settings'),
};
return <AccountPage messages={messages} locale={params.locale as LocaleCodeType} />;

View File

@@ -0,0 +1,383 @@
'use client';
import { useState, useContext, useRef } from 'react';
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
import { TokenContext } from '@/utils/TokenProvider';
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
import { LocaleCodeType } from '@/types/locale';
import { logError } from '@/utils/errorHandler';
import UserAvatar from '@/components/UserAvatar';
type ProfileSettingsPageMessages = {
profileSettings: string;
changeUsername: string;
newUsername: string;
updateUsername: string;
usernameUpdated: string;
changePassword: string;
currentPassword: string;
newPassword: string;
confirmNewPassword: string;
updatePassword: string;
passwordUpdated: string;
changeAvatar: string;
uploadAvatar: string;
removeAvatar: string;
avatarUpdated: string;
avatarRemoved: string;
maxFileSize5mb: string;
onlyImagesAllowed: string;
currentPasswordIncorrect: string;
updateError: string;
invalidPassword: string;
passwordNotMatch: string;
usernameEmpty: string;
};
type Props = {
messages: ProfileSettingsPageMessages;
locale: LocaleCodeType;
};
export default function ProfileSettingsPage({ messages }: Props) {
const context = useContext(TokenContext);
const fileInputRef = useRef<HTMLInputElement>(null);
const [username, setUsername] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const handleUsernameUpdate = async () => {
if (!username.trim()) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.usernameEmpty,
});
return;
}
setIsUpdatingUsername(true);
try {
const result = await updateUsername(context.token.access_token, username);
if (result && result.user) {
// refresh username
const newToken = { ...context.token };
if (newToken.user) {
newToken.user.username = result.user.username;
}
context.setToken(newToken);
context.storeTokenToLocalStorage(newToken);
addToast({
title: 'Success',
color: 'success',
description: messages.usernameUpdated,
});
setUsername('');
}
} catch (error) {
logError('Error updating username:', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.updateError,
});
} finally {
setIsUpdatingUsername(false);
}
};
const handlePasswordUpdate = async () => {
if (!currentPassword || !newPassword) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.updateError,
});
return;
}
if (newPassword.length < 8) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.invalidPassword,
});
return;
}
if (newPassword !== confirmPassword) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.passwordNotMatch,
});
return;
}
setIsUpdatingPassword(true);
try {
await updatePassword(context.token.access_token, currentPassword, newPassword);
addToast({
title: 'Success',
color: 'success',
description: messages.passwordUpdated,
});
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (error) {
logError('Error updating password:', error);
const errorMessage = error instanceof Error ? error.message : messages.updateError;
if (errorMessage.includes('incorrect')) {
addToast({
title: 'Error',
color: 'danger',
description: messages.currentPasswordIncorrect,
});
} else {
addToast({
title: 'Error',
color: 'danger',
description: messages.updateError,
});
}
} finally {
setIsUpdatingPassword(false);
}
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.onlyImagesAllowed,
});
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.maxFileSize5mb,
});
return;
}
setIsUploadingAvatar(true);
try {
const result = await uploadAvatar(context.token.access_token, file);
if (result && result.user) {
const newToken = { ...context.token };
if (newToken.user) {
newToken.user = result.user;
}
context.setToken(newToken);
context.storeTokenToLocalStorage(newToken);
addToast({
title: 'Success',
color: 'success',
description: messages.avatarUpdated,
});
}
} catch (error) {
logError('Error uploading avatar:', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.updateError,
});
} finally {
setIsUploadingAvatar(false);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleAvatarRemove = async () => {
setIsUploadingAvatar(true);
try {
const result = await deleteAvatar(context.token.access_token);
if (result && result.user) {
const newToken = { ...context.token };
if (newToken.user) {
newToken.user = result.user;
}
context.setToken(newToken);
context.storeTokenToLocalStorage(newToken);
addToast({
title: 'Success',
color: 'success',
description: messages.avatarRemoved,
});
}
} catch (error) {
logError('Error removing avatar:', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.updateError,
});
} finally {
setIsUploadingAvatar(false);
}
};
if (!context.isSignedIn()) {
return null;
}
return (
<div className="container mx-auto max-w-xl pt-6 px-6 flex-grow">
<h1 className="text-2xl font-bold mb-6">{messages.profileSettings}</h1>
{/* Change Username */}
<Card className="mb-6">
<CardHeader>
<h2 className="text-large font-semibold">{messages.changeUsername}</h2>
</CardHeader>
<CardBody>
<form>
<div className="space-y-4">
<Input
size="sm"
autoComplete="username"
label={messages.newUsername}
placeholder={context.token?.user?.username || ''}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</form>
</CardBody>
<CardFooter className="flex justify-end">
<Button
color="primary"
onPress={handleUsernameUpdate}
isLoading={isUpdatingUsername}
isDisabled={!username.trim()}
size="sm"
>
{messages.updateUsername}
</Button>
</CardFooter>
</Card>
{/* Change Password */}
<Card className="mb-6">
<CardHeader>
<h2 className="text-large font-semibold">{messages.changePassword}</h2>
</CardHeader>
<CardBody>
<form>
<div className="space-y-4">
{/* hidden username field for accessibility */}
<input
type="text"
name="username"
autoComplete="username"
value={context.token?.user?.username || ''}
style={{ display: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
size="sm"
type="password"
autoComplete="current-password"
label={messages.currentPassword}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<Input
size="sm"
type="password"
autoComplete="new-password"
label={messages.newPassword}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
size="sm"
type="password"
autoComplete="new-password"
label={messages.confirmNewPassword}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</form>
</CardBody>
<CardFooter className="flex justify-end">
<Button
size="sm"
color="primary"
onPress={handlePasswordUpdate}
isLoading={isUpdatingPassword}
isDisabled={!currentPassword || !newPassword || !confirmPassword}
>
{messages.updatePassword}
</Button>
</CardFooter>
</Card>
{/* Change Avatar */}
<Card className="mb-6">
<CardHeader>
<h2 className="text-large font-semibold">{messages.changeAvatar}</h2>
</CardHeader>
<CardBody>
<form>
<div className="space-y-4">
<div className="flex items-center gap-4">
<UserAvatar
size={96}
username={context.token?.user?.username}
avatarPath={context.token?.user?.avatarPath}
/>
<div className="text-sm text-gray-500">{messages.maxFileSize5mb}</div>
</div>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
</div>
</form>
</CardBody>
<CardFooter className="flex justify-end">
{context.token?.user?.avatarPath && (
<Button
size="sm"
color="danger"
className="me-2"
onPress={handleAvatarRemove}
isLoading={isUploadingAvatar}
>
{messages.removeAvatar}
</Button>
)}
<Button size="sm" color="primary" onPress={() => fileInputRef.current?.click()} isLoading={isUploadingAvatar}>
{messages.uploadAvatar}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useTranslations } from 'next-intl';
import ProfileSettingsPage from './ProfileSettingsPage';
import { PageType } from '@/types/base';
import { LocaleCodeType } from '@/types/locale';
export default function Page({ params }: PageType) {
const t = useTranslations('Auth');
const messages = {
profileSettings: t('profile_settings'),
changeUsername: t('change_username'),
newUsername: t('new_username'),
updateUsername: t('update_username'),
usernameUpdated: t('username_updated'),
changePassword: t('change_password'),
currentPassword: t('current_password'),
newPassword: t('new_password'),
confirmNewPassword: t('confirm_new_password'),
updatePassword: t('update_password'),
passwordUpdated: t('password_updated'),
changeAvatar: t('change_avatar'),
uploadAvatar: t('upload_avatar'),
removeAvatar: t('remove_avatar'),
avatarUpdated: t('avatar_updated'),
avatarRemoved: t('avatar_removed'),
maxFileSize5mb: t('max_file_size_5mb'),
onlyImagesAllowed: t('only_images_allowed'),
currentPasswordIncorrect: t('current_password_incorrect'),
updateError: t('update_error'),
invalidPassword: t('invalid_password'),
passwordNotMatch: t('password_not_match'),
usernameEmpty: t('username_empty'),
};
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
}

View File

@@ -14,9 +14,9 @@ import {
DropdownItem,
} from '@heroui/react';
import { ChevronDown } from 'lucide-react';
import Avatar from 'boring-avatars';
import { UserType, AdminMessages } from '@/types/user';
import { roles } from '@/config/selection';
import UserAvatar from '@/components/UserAvatar';
type Props = {
users: UserType[];
@@ -62,14 +62,7 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
switch (columnKey) {
case 'avatar':
return (
<Avatar
size={24}
name={user.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
);
return <UserAvatar size={24} username={user.username} avatarPath={user.avatarPath} />;
case 'id':
return <span>{cellValue}</span>;
case 'email':

View File

@@ -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.21</TableCell>
<TableCell>1.0.0-beta.22</TableCell>
</TableRow>
<TableRow key="2">
<TableCell>{messages.api_server}</TableCell>

View File

@@ -1,8 +1,8 @@
import { useMemo, useCallback } from 'react';
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
import Avatar from 'boring-avatars';
import { UserType } from '@/types/user';
import { MembersMessages } from '@/types/member';
import UserAvatar from '@/components/UserAvatar';
type Props = {
candidates: UserType[];
@@ -24,14 +24,7 @@ export default function MembersTable({ candidates, onAddPress, messages }: Props
switch (columnKey) {
case 'avatar':
return (
<Avatar
size={16}
name={candidate.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
);
return <UserAvatar size={24} username={candidate.username} avatarPath={candidate.avatarPath} />;
case 'email':
return cellValue;
case 'username':

View File

@@ -14,10 +14,10 @@ import {
DropdownItem,
} from '@heroui/react';
import { ChevronDown } from 'lucide-react';
import Avatar from 'boring-avatars';
import { MemberType, UserType } from '@/types/user';
import { memberRoles } from '@/config/selection';
import { MembersMessages } from '@/types/member';
import UserAvatar from '@/components/UserAvatar';
type Props = {
members: MemberType[];
@@ -56,14 +56,7 @@ export default function MembersTable({ members, isDisabled, onChangeRole, onDele
switch (columnKey) {
case 'avatar':
return (
<Avatar
size={24}
name={member.User.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
);
return <UserAvatar size={24} username={member.User.username} avatarPath={member.User.avatarPath} />;
case 'email':
return member.User.email;
case 'username':

View File

@@ -1,7 +1,6 @@
'use client';
import { useState, useEffect, useContext } from 'react';
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
import Avatar from 'boring-avatars';
import { Pencil, Trash } from 'lucide-react';
import { SettingsMessages } from '@/types/settings';
import { TokenContext } from '@/utils/TokenProvider';
@@ -13,6 +12,7 @@ import ProjectDialog from '@/components/ProjectDialog';
import { UserType } from '@/types/user';
import { findUser } from '@/utils/usersControl';
import { logError } from '@/utils/errorHandler';
import UserAvatar from '@/components/UserAvatar';
type Props = {
projectId: string;
@@ -130,12 +130,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
<TableCell>{messages.projectOwner}</TableCell>
<TableCell>
<div className="flex gap-2 items-center">
<Avatar
size={24}
name={owner.username}
variant="beam"
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
/>
<UserAvatar size={24} username={owner.username} avatarPath={owner.avatarPath} />
<p className="">{owner.username}</p>
</div>
</TableCell>

View File

@@ -25,6 +25,7 @@ export type TokenType = {
export type TokenContextType = {
token: {
access_token: string;
expires_at: number;
user: UserType | null;
};
isSignedIn: () => boolean;
@@ -87,6 +88,7 @@ export type AdminMessages = {
export type AccountDropDownMessages = {
account: string;
profileSettings: string;
signUp: string;
signIn: string;
signOut: string;

View File

@@ -28,6 +28,7 @@ function removeTokenFromLocalStorage() {
const defaultContext = {
token: {
access_token: '',
expires_at: 0,
user: null,
},
isSignedIn: () => false,

View File

@@ -62,7 +62,7 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
body: JSON.stringify(updateUserData),
};
const url = `${apiServer}/users/${userId}`;
const url = `${apiServer}/users/${userId}/role`;
try {
const response = await fetch(url, fetchOptions);
@@ -76,4 +76,117 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
}
}
export { findUser, searchUsers, updateUserRole };
async function updateUsername(jwt: string, username: string) {
const updateData = {
username,
};
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify(updateData),
};
const url = `${apiServer}/users/username`;
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 username:', error);
throw error;
}
}
async function updatePassword(jwt: string, currentPassword: string, newPassword: string) {
const updateData = {
currentPassword,
newPassword,
};
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify(updateData),
};
const url = `${apiServer}/users/password`;
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 password:', error);
throw error;
}
}
async function uploadAvatar(jwt: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
const fetchOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
},
body: formData,
};
const url = `${apiServer}/users/avatar`;
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 uploading avatar:', error);
throw error;
}
}
async function deleteAvatar(jwt: string) {
const fetchOptions = {
method: 'DELETE',
headers: {
Authorization: `Bearer ${jwt}`,
},
};
const url = `${apiServer}/users/avatar`;
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 deleting avatar:', error);
throw error;
}
}
export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar };

26
package-lock.json generated
View File

@@ -16,9 +16,7 @@
"eslint-config-next": "^15.3.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^4.21.0",
"globals": "^16.0.0",
"prettier": "^3.3.3",
@@ -3438,15 +3436,6 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
}
},
"node_modules/eslint-plugin-only-warn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-only-warn/-/eslint-plugin-only-warn-1.1.0.tgz",
"integrity": "sha512-2tktqUAT+Q3hCAU0iSf4xAN1k9zOpjK5WO8104mB0rT/dGhOa09582HN5HlbxNbPRZ0THV7nLGvzugcNOSjzfA==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -3517,21 +3506,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/eslint-plugin-unused-imports": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
"integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==",
"dev": true,
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
"eslint": "^9.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",

View File

@@ -22,9 +22,7 @@
"eslint-config-next": "^15.3.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^4.21.0",
"globals": "^16.0.0",
"prettier": "^3.3.3",