feat: password reset by admin (#360)
This commit is contained in:
42
backend/routes/users/adminResetPassword.js
Normal file
42
backend/routes/users/adminResetPassword.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロジェクト一覧",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<UserType | null>(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 (
|
||||
<>
|
||||
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
||||
@@ -130,7 +153,13 @@ export default function AdminPage({ messages, locale }: Props) {
|
||||
<h3 className="font-bold">{messages.userManagement}</h3>
|
||||
</div>
|
||||
|
||||
<UsersTable users={users} myself={myself} onChangeRole={handleChangeRole} messages={messages} />
|
||||
<UsersTable
|
||||
users={users}
|
||||
myself={myself}
|
||||
onChangeRole={handleChangeRole}
|
||||
openResetDialog={openResetDialog}
|
||||
messages={messages}
|
||||
/>
|
||||
<Button className="mt-4" color="danger" variant="bordered" onPress={() => setIsConfirmDialogOpen(true)}>
|
||||
{messages.quitAdmin}
|
||||
</Button>
|
||||
@@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) {
|
||||
confirmText={messages.quitConfirm}
|
||||
deleteText={messages.quit}
|
||||
/>
|
||||
|
||||
<PasswordResetDialog
|
||||
isOpen={isResetDialogOpen}
|
||||
onCancel={() => setIsResetDialogOpen(false)}
|
||||
onReset={onReset}
|
||||
messages={messages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
76
frontend/src/app/[locale]/admin/PasswordResetDialog.tsx
Normal file
76
frontend/src/app/[locale]/admin/PasswordResetDialog.tsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">{messages.resetPassword}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-3">
|
||||
<form>
|
||||
{errorMessage && <div className="my-3 text-danger">{errorMessage}</div>}
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
label="New Password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="mt-3"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onCancel}>
|
||||
{messages.close}
|
||||
</Button>
|
||||
<Button color="danger" onPress={validate}>
|
||||
{messages.reset}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<SortDescriptor>({
|
||||
@@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
case 'actions':
|
||||
return (
|
||||
!isMyself(myself, user) && (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" className="bg-transparent rounded-full">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Static Actions">
|
||||
<DropdownItem key="edit" onPress={() => openResetDialog(user)}>
|
||||
{messages.resetPassword}
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -84,6 +84,10 @@ export type AdminMessages = {
|
||||
roleChanged: string;
|
||||
lostAdminAuth: string;
|
||||
atLeast: string;
|
||||
resetPassword: string;
|
||||
reset: string;
|
||||
invalidPassword: string;
|
||||
passwordNotMatch: string;
|
||||
};
|
||||
|
||||
export type AccountDropDownMessages = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user