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

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