diff --git a/backend/routes/cases/clone.js b/backend/routes/cases/clone.js index 1dbc890..2c95e4e 100644 --- a/backend/routes/cases/clone.js +++ b/backend/routes/cases/clone.js @@ -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; }); diff --git a/backend/routes/folders/clone.js b/backend/routes/folders/clone.js index b9c4e38..db816e7 100644 --- a/backend/routes/folders/clone.js +++ b/backend/routes/folders/clone.js @@ -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; }); diff --git a/backend/routes/users/updateAvatar.js b/backend/routes/users/updateAvatar.js new file mode 100644 index 0000000..a2bb389 --- /dev/null +++ b/backend/routes/users/updateAvatar.js @@ -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; +} diff --git a/backend/routes/users/updatePassword.js b/backend/routes/users/updatePassword.js new file mode 100644 index 0000000..0a19ed1 --- /dev/null +++ b/backend/routes/users/updatePassword.js @@ -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; +} diff --git a/backend/routes/users/updatePassword.test.js b/backend/routes/users/updatePassword.test.js new file mode 100644 index 0000000..30c46f8 --- /dev/null +++ b/backend/routes/users/updatePassword.test.js @@ -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); + }); +}); diff --git a/backend/routes/users/update.js b/backend/routes/users/updateRole.js similarity index 95% rename from backend/routes/users/update.js rename to backend/routes/users/updateRole.js index d45f757..f036a39 100644 --- a/backend/routes/users/update.js +++ b/backend/routes/users/updateRole.js @@ -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) { diff --git a/backend/routes/users/update.test.js b/backend/routes/users/updateRole.test.js similarity index 86% rename from backend/routes/users/update.test.js rename to backend/routes/users/updateRole.test.js index 2e9acd4..209521f 100644 --- a/backend/routes/users/update.test.js +++ b/backend/routes/users/updateRole.test.js @@ -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, }); diff --git a/backend/routes/users/updateUsername.js b/backend/routes/users/updateUsername.js new file mode 100644 index 0000000..9780df9 --- /dev/null +++ b/backend/routes/users/updateUsername.js @@ -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; +} diff --git a/backend/routes/users/updateUsername.test.js b/backend/routes/users/updateUsername.test.js new file mode 100644 index 0000000..d8593dd --- /dev/null +++ b/backend/routes/users/updateUsername.test.js @@ -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); + }); +}); diff --git a/backend/server.js b/backend/server.js index 6c83507..a074eb3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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)); diff --git a/docs/docs/dev/er.md b/docs/docs/dev/er.md index 5b95c3d..5d80397 100644 --- a/docs/docs/dev/er.md +++ b/docs/docs/dev/er.md @@ -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 diff --git a/docs/docs/getstarted/selfhost.md b/docs/docs/getstarted/selfhost.md index 0fbe953..acd22cf 100644 --- a/docs/docs/getstarted/selfhost.md +++ b/docs/docs/getstarted/selfhost.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1be3b86..db2473c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/frontend/components/UserAvatar.tsx b/frontend/components/UserAvatar.tsx index 22204d9..040cf25 100644 --- a/frontend/components/UserAvatar.tsx +++ b/frontend/components/UserAvatar.tsx @@ -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() ? ( - - ) : ( - - ); +export default function UserAvatar({ size, username, avatarPath }: Props) { + if (username) { + if (avatarPath) { + return ; + } else { + return ( + + ); + } + } else { + return ; + } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f93afdb..1036c3b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index f788c18..5bf5bb2 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -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": "ユーザー管理", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 358c29e..3621182 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -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", diff --git a/frontend/src/app/[locale]/DropdownAccount.tsx b/frontend/src/app/[locale]/DropdownAccount.tsx index 23ab32a..496b4a3 100644 --- a/frontend/src/app/[locale]/DropdownAccount.tsx +++ b/frontend/src/app/[locale]/DropdownAccount.tsx @@ -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: , + icon: ( + + ), onPress: () => { router.push('/account', { locale: locale }); onItemPress(); }, }, + { + uid: 'profile', + title: messages.profileSettings, + icon: , + 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 diff --git a/frontend/src/app/[locale]/account/page.tsx b/frontend/src/app/[locale]/account/page.tsx index 46dce8e..e2a53a8 100644 --- a/frontend/src/app/[locale]/account/page.tsx +++ b/frontend/src/app/[locale]/account/page.tsx @@ -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 ; diff --git a/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx b/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx new file mode 100644 index 0000000..8a95e03 --- /dev/null +++ b/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx @@ -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(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) => { + 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 ( +
+

{messages.profileSettings}

+ + {/* Change Username */} + + +

{messages.changeUsername}

+
+ +
+
+ setUsername(e.target.value)} + /> +
+
+
+ + + +
+ + {/* Change Password */} + + +

{messages.changePassword}

+
+ +
+
+ {/* hidden username field for accessibility */} + + setCurrentPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> +
+
+
+ + + +
+ + {/* Change Avatar */} + + +

{messages.changeAvatar}

+
+ +
+
+
+ +
{messages.maxFileSize5mb}
+
+
+ +
+
+
+
+ + {context.token?.user?.avatarPath && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/account/settings/page.tsx b/frontend/src/app/[locale]/account/settings/page.tsx new file mode 100644 index 0000000..d2d8e94 --- /dev/null +++ b/frontend/src/app/[locale]/account/settings/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/[locale]/admin/UsersTable.tsx b/frontend/src/app/[locale]/admin/UsersTable.tsx index ea50468..ea90486 100644 --- a/frontend/src/app/[locale]/admin/UsersTable.tsx +++ b/frontend/src/app/[locale]/admin/UsersTable.tsx @@ -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 ( - - ); + return ; case 'id': return {cellValue}; case 'email': diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index 0381686..82ec700 100644 --- a/frontend/src/app/[locale]/health/HealthPage.tsx +++ b/frontend/src/app/[locale]/health/HealthPage.tsx @@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) { {messages.unittcms_version} - 1.0.0-beta.21 + 1.0.0-beta.22 {messages.api_server} diff --git a/frontend/src/app/[locale]/projects/[projectId]/members/CandidatesTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/members/CandidatesTable.tsx index c7a9ec7..30ed4e6 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/members/CandidatesTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/members/CandidatesTable.tsx @@ -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 ( - - ); + return ; case 'email': return cellValue; case 'username': diff --git a/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx index ad046d7..9a7cacf 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx @@ -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 ( - - ); + return ; case 'email': return member.User.email; case 'username': diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx index 4e27a61..8881564 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -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 {messages.projectOwner}
- +

{owner.username}

diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 78acc82..993b4b3 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -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; diff --git a/frontend/utils/TokenProvider.tsx b/frontend/utils/TokenProvider.tsx index c34ad7e..f500120 100644 --- a/frontend/utils/TokenProvider.tsx +++ b/frontend/utils/TokenProvider.tsx @@ -28,6 +28,7 @@ function removeTokenFromLocalStorage() { const defaultContext = { token: { access_token: '', + expires_at: 0, user: null, }, isSignedIn: () => false, diff --git a/frontend/utils/usersControl.ts b/frontend/utils/usersControl.ts index 4c50c3b..cc5836c 100644 --- a/frontend/utils/usersControl.ts +++ b/frontend/utils/usersControl.ts @@ -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 }; diff --git a/package-lock.json b/package-lock.json index 80bf588..5ecd736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f168b20..a8df1ae 100644 --- a/package.json +++ b/package.json @@ -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",