Added reset password for admin

This commit is contained in:
Mitchell Magro 2025-11-11 14:46:30 +01:00
parent a3ad2a8937
commit c686965b37
5 changed files with 261 additions and 17 deletions

View File

@ -48,10 +48,12 @@ export async function POST(request: Request) {
// Handle backend response
if (!resp.ok) {
const errorData = await safeJson(resp);
console.error("Registration proxy error:", errorData);
return NextResponse.json(
{
success: false,
message: errorData?.message || "Registration failed",
error: errorData?.error || "Unknown error",
},
{ status: resp.status }
);

View File

@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ success: false, message: "User ID is required" },
{ status: 400 }
);
}
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "No authentication token found" },
{ status: 401 }
);
}
const resp = await fetch(
`${BE_BASE_URL}/api/v1/auth/reset-password/${encodeURIComponent(id)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
// Attempt to parse JSON; fall back to status-only response
let data: unknown = null;
try {
data = await resp.json();
} catch {
data = { success: resp.ok };
}
return NextResponse.json(data ?? { success: resp.ok }, {
status: resp.status,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ success: false, message: "Internal server error", error: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,49 @@
import React from "react";
import { Stack, Typography, Button } from "@mui/material";
interface ConfirmProps {
onSubmit: () => void | Promise<void>;
onClose: () => void;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
disabled?: boolean;
}
/**
* Simple confirmation content to be rendered inside the shared Modal.
* Shows an "Are you sure?" message and calls the parent's onSubmit when confirmed.
*/
const Confirm: React.FC<ConfirmProps> = ({
onSubmit,
onClose,
message = "Are you sure you want to continue?",
confirmLabel = "Yes, continue",
cancelLabel = "Cancel",
disabled = false,
}) => {
const handleConfirm = async () => {
await Promise.resolve(onSubmit());
};
return (
<Stack spacing={3}>
<Typography variant="body1">{message}</Typography>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={onClose} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color="primary"
variant="contained"
onClick={handleConfirm}
disabled={disabled}
>
{confirmLabel}
</Button>
</Stack>
</Stack>
);
};
export default Confirm;

View File

@ -1,4 +1,5 @@
import { useState } from "react";
import toast from "react-hot-toast";
import {
Card,
CardContent,
@ -13,15 +14,20 @@ import {
import {
Edit,
Delete,
Visibility,
VpnKey,
InfoOutlined,
AdminPanelSettings,
History,
ContentCopy,
} from "@mui/icons-material";
import EditUser from "./EditUser/EditUser";
import { IUser } from "../Pages/Admin/Users/interfaces";
import DeleteUser from "./DeleteUser/DeleteUser";
import Modal from "@/app/components/Modal/Modal";
import { useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/store";
import { resetPassword } from "@/app/redux/auth/authSlice";
import Confirm from "../../components/Confirm/Confirm";
import { ThunkSuccess } from "@/app/redux/types";
import "./User.scss";
interface Props {
@ -30,9 +36,11 @@ interface Props {
export default function UserRoleCard({ user }: Props) {
const [isEditing, setIsEditing] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [openDeleteUser, setOpenDeleteUser] = useState(false);
const dispatch = useDispatch<AppDispatch>();
const { username, first_name, last_name, email, groups } = user;
const [newPassword, setNewPassword] = useState<string | null>(null);
const handleEditClick = () => {
setIsEditing(!isEditing);
};
@ -41,6 +49,41 @@ export default function UserRoleCard({ user }: Props) {
setOpenDeleteUser(true);
};
const handleResetPasswordSubmit = async () => {
try {
const resultAction = await dispatch(
resetPassword({ id: user.id as string })
);
if (resetPassword.fulfilled.match(resultAction)) {
setNewPassword(
(
resultAction.payload as ThunkSuccess<{
success: boolean;
message: string;
newPassword: string | null;
}>
)?.newPassword || null
);
toast.success(
(
resultAction.payload as ThunkSuccess<{
success: boolean;
message: string;
newPassword: string | null;
}>
)?.message || "Password reset successfully"
);
} else if (resetPassword.rejected.match(resultAction)) {
toast.error(
(resultAction.payload as string) || "Failed to reset password"
);
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unexpected error";
toast.error(message);
}
};
return (
<Card sx={{ mb: 2, minWidth: "100%" }}>
<CardContent>
@ -57,20 +100,18 @@ export default function UserRoleCard({ user }: Props) {
{true && (
<Chip icon={<AdminPanelSettings />} label="Admin" size="small" />
)}
<IconButton>
<History />
</IconButton>
<Tooltip title="Edit">
<IconButton onClick={handleEditClick}>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title="View">
<IconButton>
<Visibility />
</IconButton>
</Tooltip>
<Tooltip title="Reset Password">
<Tooltip
title="Reset Password"
onClick={() => {
setNewPassword(null);
setShowConfirmModal(true);
}}
>
<IconButton>
<VpnKey />
</IconButton>
@ -85,11 +126,6 @@ export default function UserRoleCard({ user }: Props) {
{/* Merchants + Roles */}
<Box mt={2}>
<Typography fontWeight="bold">Merchants</Typography>
{/* <Stack direction="row" spacing={1} mt={1}>
{merchants.map((m) => (
<Chip key={m} label={m} size="small" />
))}
</Stack> */}
</Box>
<Box mt={2}>
@ -121,6 +157,48 @@ export default function UserRoleCard({ user }: Props) {
onClose={() => setOpenDeleteUser(false)}
/>
)}
{showConfirmModal && (
<Modal
open={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
title="Reset Password"
>
{newPassword && (
<div className="reset-password__content">
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="body1">
<code>{newPassword}</code>
</Typography>
<Tooltip title="Copy to clipboard">
<IconButton
aria-label="Copy temporary password"
onClick={async () => {
try {
await navigator.clipboard.writeText(newPassword);
toast.success("Copied to clipboard");
} catch {
toast.error("Failed to copy");
}
}}
size="small"
>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</div>
)}
{!newPassword && (
<Confirm
onClose={() => setShowConfirmModal(false)}
onSubmit={handleResetPasswordSubmit}
message="Are you sure you want to reset the password for this user?"
confirmLabel="Reset Password"
cancelLabel="Cancel"
/>
)}
</Modal>
)}
</CardContent>
</Card>
);

View File

@ -154,6 +154,44 @@ export const changePassword = createAsyncThunk<
}
);
// ---------------- Reset Password (Admin) ----------------
export const resetPassword = createAsyncThunk<
ThunkSuccess<{ success: boolean }>,
{ id: string },
{ rejectValue: ThunkError }
>("auth/resetPassword", async ({ id }, { rejectWithValue }) => {
try {
const res = await fetch(
`/api/auth/reset-password/${encodeURIComponent(id)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
}
);
const data = await res.json();
if (!res.ok) {
return rejectWithValue(
(data.message as string) || "Password reset failed"
);
}
return {
success: data.success ?? true,
message: data.message || "Password reset successfully",
newPassword: data.user?.password || null,
} as ThunkSuccess<{
success: boolean;
message: string;
newPassword: string | null;
}>;
} catch (err: unknown) {
return rejectWithValue(
(err as Error).message || "Network error during password reset"
);
}
});
// ---------------- Unified Validate Auth ----------------
export const validateAuth = createAsyncThunk<
{ tokenInfo: TokenInfo | null },
@ -327,6 +365,23 @@ const authSlice = createSlice({
state.error = action.payload as string;
state.authMessage = action.payload as string;
state.addedUser = null;
})
// Reset Password (Admin)
.addCase(resetPassword.pending, state => {
state.status = "loading";
state.authMessage = "Resetting password...";
})
.addCase(resetPassword.fulfilled, (state, action) => {
state.status = "succeeded";
state.authMessage = action.payload.message;
state.mustChangePassword = action.payload.success;
toast.success(action.payload.message || "Password reset successfully");
})
.addCase(resetPassword.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.authMessage = action.payload as string;
toast.error((action.payload as string) || "Password reset failed");
});
},
});