feat: password reset by admin (#360)

This commit is contained in:
kimatata
2025-12-07 20:37:38 +09:00
committed by GitHub
parent 805c1f50b6
commit 2adb24dbaa
11 changed files with 244 additions and 9 deletions

View 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;
}

View File

@@ -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));

View File

@@ -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",

View File

@@ -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": "プロジェクト一覧",

View File

@@ -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",

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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 (

View File

@@ -84,6 +84,10 @@ export type AdminMessages = {
roleChanged: string;
lostAdminAuth: string;
atLeast: string;
resetPassword: string;
reset: string;
invalidPassword: string;
passwordNotMatch: string;
};
export type AccountDropDownMessages = {

View File

@@ -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,
};