diff --git a/backend/routes/users/adminResetPassword.js b/backend/routes/users/adminResetPassword.js new file mode 100644 index 0000000..e2d9370 --- /dev/null +++ b/backend/routes/users/adminResetPassword.js @@ -0,0 +1,42 @@ +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, verifyAdmin } = authMiddleware(sequelize); + const User = defineUser(sequelize, DataTypes); + + // Admin resets another user's password + router.put('/:id/password', verifySignedIn, verifyAdmin, async (req, res) => { + try { + const { id } = req.params; + const { newPassword } = req.body; + + if (!newPassword) { + return res.status(400).send('New password is required'); + } + + if (newPassword.length < 8) { + return res.status(400).send('New password must be at least 8 characters'); + } + + const user = await User.findByPk(id); + if (!user) { + return res.status(404).send('User not found'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + await user.update({ password: hashedPassword }); + + return res.json({ user: { id: user.id } }); + } catch (error) { + console.error(error); + return res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index db432b8..52bc492 100644 --- a/backend/server.js +++ b/backend/server.js @@ -52,6 +52,7 @@ import usersFindRoute from './routes/users/find.js'; import usersSearchRoute from './routes/users/search.js'; import usersUpdateUsernameRoute from './routes/users/updateUsername.js'; import usersUpdatePasswordRoute from './routes/users/updatePassword.js'; +import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js'; import usersUpdateAvatarRoute from './routes/users/updateAvatar.js'; import usersUpdateRoleRoute from './routes/users/updateRole.js'; import signUpRoute from './routes/users/signup.js'; @@ -61,6 +62,7 @@ app.use('/users', usersFindRoute(sequelize)); app.use('/users', usersSearchRoute(sequelize)); app.use('/users', usersUpdateUsernameRoute(sequelize)); app.use('/users', usersUpdatePasswordRoute(sequelize)); +app.use('/users', usersAdminResetPasswordRoute(sequelize)); app.use('/users', usersUpdateAvatarRoute(sequelize)); app.use('/users', usersUpdateRoleRoute(sequelize)); app.use('/users', signUpRoute(sequelize)); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0419ca1..83c5ee4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -159,7 +159,11 @@ "quit_confirm": "If you relinquish your administrator privileges, you will no longer be able to access the administration page.", "role_changed": "Role changed", "lost_admin_auth": "Lost admin authority", - "at_least": "At least one administrator is required" + "at_least": "At least one administrator is required", + "reset_password": "Reset password", + "reset": "Reset", + "invalid_password": "Password must be at least 8 characters", + "password_not_match": "Pasword does not match" }, "Projects": { "project_list": "Project List", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index d194d95..9827ae1 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -159,7 +159,11 @@ "quit_confirm": "管理者権限を放棄すると管理ページにアクセスできなくなります。", "role_changed": "ロールを更新しました", "lost_admin_auth": "管理者権限を失いました", - "at_least": "最低1人以上の管理者が必要です。" + "at_least": "最低1人以上の管理者が必要です。", + "reset_password": "パスワードをリセット", + "reset": "リセット", + "invalid_password": "パスワードは8文字以上である必要があります", + "password_not_match": "パスワードが一致しません" }, "Projects": { "project_list": "プロジェクト一覧", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 1d9133f..f372406 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -159,7 +159,11 @@ "quit_confirm": "Se você renunciar aos seus privilégios de administrador, não poderá mais acessar a página de administração.", "role_changed": "Função alterada", "lost_admin_auth": "Perdeu a autoridade de administrador", - "at_least": "É necessário pelo menos um administrador" + "at_least": "É necessário pelo menos um administrador", + "reset_password": "Redefinir senha", + "reset": "Redefinir", + "invalid_password": "A senha deve ter pelo menos 8 caracteres", + "password_not_match": "A senha não corresponde" }, "Projects": { "project_list": "Lista de Projetos", diff --git a/frontend/src/app/[locale]/admin/AdminPage.tsx b/frontend/src/app/[locale]/admin/AdminPage.tsx index 37f8303..1946e84 100644 --- a/frontend/src/app/[locale]/admin/AdminPage.tsx +++ b/frontend/src/app/[locale]/admin/AdminPage.tsx @@ -2,14 +2,15 @@ import { useState, useEffect, useContext } from 'react'; import { Button, addToast } from '@heroui/react'; import UsersTable from './UsersTable'; +import PasswordResetDialog from './PasswordResetDialog'; +import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { UserType, AdminMessages } from '@/types/user'; import { TokenContext } from '@/utils/TokenProvider'; import { useRouter } from '@/src/i18n/routing'; import Config from '@/config/config'; import { LocaleCodeType } from '@/types/locale'; -import { updateUserRole } from '@/utils/usersControl'; +import { updateUserRole, adminResetPassword } from '@/utils/usersControl'; import { roles } from '@/config/selection'; -import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { logError } from '@/utils/errorHandler'; const apiServer = Config.apiServer; @@ -123,6 +124,28 @@ export default function AdminPage({ messages, locale }: Props) { } }; + // Reset password dialog state + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + + const openResetDialog = (user: UserType) => { + setResetTarget(user); + setIsResetDialogOpen(true); + }; + + const onReset = async (newPassword: string) => { + setIsResetDialogOpen(false); + if (!resetTarget || !resetTarget.id) return; + + try { + await adminResetPassword(tokenContext.token.access_token, resetTarget.id, newPassword); + addToast({ title: 'Success', color: 'success', description: 'Password updated' }); + setResetTarget(null); + } catch (error: unknown) { + logError('Failed to reset password', error); + } + }; + return ( <>
@@ -130,7 +153,13 @@ export default function AdminPage({ messages, locale }: Props) {

{messages.userManagement}

- + @@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) { confirmText={messages.quitConfirm} deleteText={messages.quit} /> + + setIsResetDialogOpen(false)} + onReset={onReset} + messages={messages} + /> ); } diff --git a/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx new file mode 100644 index 0000000..c317e2c --- /dev/null +++ b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx @@ -0,0 +1,76 @@ +import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input } from '@heroui/react'; +import { useState } from 'react'; +import { isValidPassword } from '../account/validate'; +import { AdminMessages } from '@/types/user'; + +type Props = { + isOpen: boolean; + onCancel: () => void; + onReset: (newPassword: string) => void; + messages: AdminMessages; +}; + +export default function PasswordResetDialog({ isOpen, onCancel, onReset, messages }: Props) { + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const validate = async () => { + if (newPassword !== confirmPassword) { + setErrorMessage(messages.passwordNotMatch); + return; + } + + if (!isValidPassword(newPassword)) { + setErrorMessage(messages.invalidPassword); + return; + } + + onReset(newPassword); + }; + + return ( + { + onCancel(); + }} + > + + {messages.resetPassword} + +
+
+ {errorMessage &&
{errorMessage}
} + setNewPassword(e.target.value)} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + autoComplete="new-password" + className="mt-3" + /> +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/admin/UsersTable.tsx b/frontend/src/app/[locale]/admin/UsersTable.tsx index ea90486..11f2f88 100644 --- a/frontend/src/app/[locale]/admin/UsersTable.tsx +++ b/frontend/src/app/[locale]/admin/UsersTable.tsx @@ -13,7 +13,7 @@ import { DropdownMenu, DropdownItem, } from '@heroui/react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, MoreVertical } from 'lucide-react'; import { UserType, AdminMessages } from '@/types/user'; import { roles } from '@/config/selection'; import UserAvatar from '@/components/UserAvatar'; @@ -22,16 +22,18 @@ type Props = { users: UserType[]; myself: UserType | null; onChangeRole: (userEdit: UserType, role: number) => void; + openResetDialog: (user: UserType) => void; messages: AdminMessages; }; -export default function UsersTable({ users, myself, onChangeRole, messages }: Props) { +export default function UsersTable({ users, myself, onChangeRole, openResetDialog, messages }: Props) { const headerColumns = [ { name: messages.avatar, uid: 'avatar', sortable: false }, { name: messages.id, uid: 'id', sortable: true }, { name: messages.email, uid: 'email', sortable: true }, { name: messages.username, uid: 'username', sortable: true }, { name: messages.role, uid: 'role', sortable: true }, + { name: '', uid: 'actions', sortable: false }, ]; const [sortDescriptor, setSortDescriptor] = useState({ @@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr ); + case 'actions': + return ( + !isMyself(myself, user) && ( + + + + + + openResetDialog(user)}> + {messages.resetPassword} + + + + ) + ); + default: return cellValue; } diff --git a/frontend/src/app/[locale]/admin/page.tsx b/frontend/src/app/[locale]/admin/page.tsx index b47bf46..f71ac06 100644 --- a/frontend/src/app/[locale]/admin/page.tsx +++ b/frontend/src/app/[locale]/admin/page.tsx @@ -32,6 +32,10 @@ export default function Page({ params }: PageType) { roleChanged: t('role_changed'), lostAdminAuth: t('lost_admin_auth'), atLeast: t('at_least'), + resetPassword: t('reset_password'), + reset: t('reset'), + invalidPassword: t('invalid_password'), + passwordNotMatch: t('password_not_match'), }; return ( diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 993b4b3..cc3e204 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -84,6 +84,10 @@ export type AdminMessages = { roleChanged: string; lostAdminAuth: string; atLeast: string; + resetPassword: string; + reset: string; + invalidPassword: string; + passwordNotMatch: string; }; export type AccountDropDownMessages = { diff --git a/frontend/utils/usersControl.ts b/frontend/utils/usersControl.ts index cc5836c..211e69f 100644 --- a/frontend/utils/usersControl.ts +++ b/frontend/utils/usersControl.ts @@ -189,4 +189,43 @@ async function deleteAvatar(jwt: string) { } } -export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar }; +async function adminResetPassword(jwt: string, userId: number, newPassword: string) { + const updateData = { + newPassword, + }; + + const fetchOptions = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updateData), + }; + + const url = `${apiServer}/users/${userId}/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 admin resetting password:', error); + throw error; + } +} + +export { + findUser, + searchUsers, + updateUserRole, + updateUsername, + updatePassword, + uploadAvatar, + deleteAvatar, + adminResetPassword, +};