From 792299684559d4b65d54f60c2975fd77310bee81 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:06:25 +0900 Subject: [PATCH] feat: user profile customization: username, password, and avatar (#315) --- backend/routes/users/updateAvatar.js | 140 +++++++ backend/routes/users/updatePassword.js | 49 +++ backend/routes/users/updatePassword.test.js | 113 ++++++ .../routes/users/{update.js => updateRole.js} | 2 +- .../{update.test.js => updateRole.test.js} | 14 +- backend/routes/users/updateUsername.js | 40 ++ backend/routes/users/updateUsername.test.js | 86 ++++ backend/server.js | 10 +- frontend/components/UserAvatar.tsx | 36 +- frontend/messages/en.json | 23 +- frontend/messages/ja.json | 23 +- frontend/messages/pt-BR.json | 23 +- frontend/src/app/[locale]/DropdownAccount.tsx | 23 +- frontend/src/app/[locale]/Header.tsx | 1 + .../src/app/[locale]/HeaderNavbarMenu.tsx | 20 +- .../src/app/[locale]/account/AccountPage.tsx | 36 +- frontend/src/app/[locale]/account/page.tsx | 1 + .../account/settings/ProfileSettingsPage.tsx | 383 ++++++++++++++++++ .../app/[locale]/account/settings/page.tsx | 35 ++ .../src/app/[locale]/admin/UsersTable.tsx | 11 +- .../[projectId]/members/CandidatesTable.tsx | 11 +- .../[projectId]/members/MembersTable.tsx | 12 +- .../[projectId]/settings/SettingsPage.tsx | 9 +- frontend/types/user.ts | 2 + frontend/utils/TokenProvider.tsx | 1 + frontend/utils/usersControl.ts | 117 +++++- 26 files changed, 1142 insertions(+), 79 deletions(-) create mode 100644 backend/routes/users/updateAvatar.js create mode 100644 backend/routes/users/updatePassword.js create mode 100644 backend/routes/users/updatePassword.test.js rename backend/routes/users/{update.js => updateRole.js} (95%) rename backend/routes/users/{update.test.js => updateRole.test.js} (86%) create mode 100644 backend/routes/users/updateUsername.js create mode 100644 backend/routes/users/updateUsername.test.js create mode 100644 frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx create mode 100644 frontend/src/app/[locale]/account/settings/page.tsx 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/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]/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..19b95f9 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/members/MembersTable.tsx @@ -14,10 +14,11 @@ 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 Config from '@/config/config'; +import UserAvatar from '@/components/UserAvatar'; type Props = { members: MemberType[]; @@ -56,14 +57,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 };