From 6a68d93308901b0985481801f069fe577ad74c65 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Mon, 10 Nov 2025 10:43:57 +0100 Subject: [PATCH] Added delete user - Admin Section --- app/api/dashboard/admin/users/[id]/route.ts | 45 ++++ app/features/Pages/Admin/Users/users.tsx | 7 +- .../UserRoles/DeleteUser/DeleteUser.scss | 126 +++++++++++ .../UserRoles/DeleteUser/DeleteUser.tsx | 129 +++++++++++ app/features/UserRoles/userRoleCard.tsx | 16 +- app/redux/store.ts | 2 + app/redux/user/userSlice.ts | 200 ++++++++++++++++++ 7 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 app/features/UserRoles/DeleteUser/DeleteUser.scss create mode 100644 app/features/UserRoles/DeleteUser/DeleteUser.tsx create mode 100644 app/redux/user/userSlice.ts diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 40897fb..a3fb66a 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -43,3 +43,48 @@ export async function PATCH( ); } } + +export async function DELETE( + _request: Request, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + // According to swagger: /api/v1/users/{id} + const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // Some backends return empty body for DELETE; handle safely + let data: any = null; + try { + data = await response.json(); + } catch { + data = { success: response.ok }; + } + + return NextResponse.json(data ?? { success: response.ok }, { + status: response.status, + }); + } catch (err: any) { + console.error("Proxy DELETE /api/v1/users/{id} error:", err); + return NextResponse.json( + { message: "Internal server error", error: err.message }, + { status: 500 } + ); + } +} diff --git a/app/features/Pages/Admin/Users/users.tsx b/app/features/Pages/Admin/Users/users.tsx index 68bd008..92d8c6a 100644 --- a/app/features/Pages/Admin/Users/users.tsx +++ b/app/features/Pages/Admin/Users/users.tsx @@ -32,12 +32,7 @@ const Users: React.FC = ({ users }) => { {user.username} - + diff --git a/app/features/UserRoles/DeleteUser/DeleteUser.scss b/app/features/UserRoles/DeleteUser/DeleteUser.scss new file mode 100644 index 0000000..516b56a --- /dev/null +++ b/app/features/UserRoles/DeleteUser/DeleteUser.scss @@ -0,0 +1,126 @@ +.delete-user__content { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1rem 0; +} + +.delete-user__warning { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 6px; + padding: 1rem; + + .delete-user__warning-text { + margin: 0; + color: #856404; + font-size: 0.95rem; + line-height: 1.5; + font-weight: 500; + } +} + +.delete-user__user-info { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: #f7f7f8; + border-radius: 6px; + border: 1px solid #e0e0e0; +} + +.delete-user__row { + display: flex; + flex-direction: column; + gap: 0.4rem; + + .delete-user__label { + font-weight: 600; + color: #444; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .delete-user__value { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 0.75rem 1rem; + font-size: 0.95rem; + color: #222; + word-break: break-word; + } +} + +.delete-user__error { + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 6px; + padding: 0.75rem 1rem; + color: #721c24; + font-size: 0.9rem; +} + +.delete-user__actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.delete-user__button { + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 120px; + justify-content: center; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &--cancel { + background: #f5f5f5; + color: #333; + border: 1px solid #ddd; + + &:hover:not(:disabled) { + background: #e8e8e8; + border-color: #bbb; + } + } + + &--delete { + background: #dc3545; + color: #fff; + + &:hover:not(:disabled) { + background: #c82333; + } + } +} + +/* Responsive tweak for smaller screens */ +@media (max-width: 600px) { + .delete-user__content { + gap: 1rem; + } + + .delete-user__actions { + flex-direction: column-reverse; + + .delete-user__button { + width: 100%; + } + } +} diff --git a/app/features/UserRoles/DeleteUser/DeleteUser.tsx b/app/features/UserRoles/DeleteUser/DeleteUser.tsx new file mode 100644 index 0000000..adc585b --- /dev/null +++ b/app/features/UserRoles/DeleteUser/DeleteUser.tsx @@ -0,0 +1,129 @@ +"use client"; + +import React from "react"; +import toast from "react-hot-toast"; +import Modal from "@/app/components/Modal/Modal"; +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch } from "@/app/redux/store"; +import { deleteUser, clearError } from "@/app/redux/user/userSlice"; +import { IUser } from "../../Pages/Admin/Users/interfaces"; +import Spinner from "@/app/components/Spinner/Spinner"; +import { RootState } from "@/app/redux/store"; + +import { useRouter } from "next/navigation"; +import "./DeleteUser.scss"; + +interface DeleteUserProps { + open: boolean; + onClose: () => void; + user: IUser | null; +} + +const DeleteUser: React.FC = ({ open, onClose, user }) => { + const dispatch = useDispatch(); + const router = useRouter(); + const { status, error } = useSelector((state: RootState) => state.user); + + const loading = status === "loading"; + + const handleDelete = async () => { + if (!user?.id) { + toast.error("No user selected for deletion"); + return; + } + + try { + const resultAction = await dispatch(deleteUser(user.id)); + + if (deleteUser.fulfilled.match(resultAction)) { + toast.success( + resultAction.payload.message || "User deleted successfully" + ); + dispatch(clearError()); + router.refresh(); + onClose(); + } else if (deleteUser.rejected.match(resultAction)) { + toast.error( + (resultAction.payload as string) || "Failed to delete user" + ); + } + } catch (error: any) { + toast.error(error.message || "An unexpected error occurred"); + } + }; + + const handleClose = () => { + if (!loading) { + dispatch(clearError()); + onClose(); + } + }; + + if (!user) { + return null; + } + + return ( + +
+
+

+ Are you sure you want to delete this user? This action cannot be + undone. +

+
+ +
+
+ +
{user.username}
+
+
+ +
+ {user.first_name} {user.last_name} +
+
+
+ +
{user.email}
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +}; + +export default DeleteUser; diff --git a/app/features/UserRoles/userRoleCard.tsx b/app/features/UserRoles/userRoleCard.tsx index c128850..886c195 100644 --- a/app/features/UserRoles/userRoleCard.tsx +++ b/app/features/UserRoles/userRoleCard.tsx @@ -22,6 +22,7 @@ import { import EditUser from "./EditUser/EditUser"; import "./User.scss"; import { IUser } from "../Pages/Admin/Users/interfaces"; +import DeleteUser from "./DeleteUser/DeleteUser"; interface Props { user: IUser; @@ -29,11 +30,16 @@ interface Props { export default function UserRoleCard({ user }: Props) { const [isEditing, setIsEditing] = useState(false); + const [openDeleteUser, setOpenDeleteUser] = useState(false); const { username, first_name, last_name, email, groups } = user; const handleEditClick = () => { setIsEditing(!isEditing); }; + const handleDeleteClick = () => { + setOpenDeleteUser(true); + }; + return ( @@ -69,7 +75,7 @@ export default function UserRoleCard({ user }: Props) { - + @@ -98,7 +104,6 @@ export default function UserRoleCard({ user }: Props) { ))} - {/* {extraRolesCount && } */}
{isEditing && }
+ {openDeleteUser && ( + setOpenDeleteUser(false)} + /> + )}
); diff --git a/app/redux/store.ts b/app/redux/store.ts index 71a873c..b574876 100644 --- a/app/redux/store.ts +++ b/app/redux/store.ts @@ -10,6 +10,7 @@ import advancedSearchReducer from "./advanedSearch/advancedSearchSlice"; import authReducer from "./auth/authSlice"; import uiReducer from "./ui/uiSlice"; import metadataReducer from "./metadata/metadataSlice"; +import userReducer from "./user/userSlice"; import userEpics from "./user/epic"; import authEpics from "./auth/epic"; import uiEpics from "./ui/epic"; @@ -41,6 +42,7 @@ const rootReducer = combineReducers({ auth: authReducer, metadata: metadataReducer, ui: uiReducer, + user: userReducer, }); const rootEpic = combineEpics(...userEpics, ...authEpics, ...uiEpics); diff --git a/app/redux/user/userSlice.ts b/app/redux/user/userSlice.ts new file mode 100644 index 0000000..56188f0 --- /dev/null +++ b/app/redux/user/userSlice.ts @@ -0,0 +1,200 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import { ThunkSuccess, ThunkError, IUserResponse } from "../types"; +import { IEditUserForm } from "../../features/UserRoles/User.interfaces"; +import { RootState } from "../store"; + +interface UserState { + status: "idle" | "loading" | "succeeded" | "failed"; + error: string | null; + message: string; + addedUser: IUserResponse | null; + editedUser: IUserResponse | null; +} + +const initialState: UserState = { + status: "idle", + error: null, + message: "", + addedUser: null, + editedUser: null, +}; + +// ---------------- Add User ---------------- +export const addUser = createAsyncThunk< + ThunkSuccess<{ user: IUserResponse; success: boolean }>, + IEditUserForm, + { rejectValue: ThunkError; state: RootState } +>("user/addUser", async (userData, { rejectWithValue, getState }) => { + try { + const state = getState(); + const currentUserId = state.auth.user?.id; + console.log("[DEBUG] [ADD-USER] [currentUserId]: ", currentUserId); + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...userData, creator: currentUserId || "" }), + }); + + const data = await res.json(); + + console.log("[DEBUG] [ADD-USER] [data]: ", data); + + if (!res.ok) { + return rejectWithValue(data.message || "Failed to create user"); + } + + return { + success: data.success, + message: data.message || "User created successfully", + user: data.user, + } as ThunkSuccess<{ user: IUserResponse; success: boolean }>; + } catch (err: unknown) { + return rejectWithValue( + (err as Error).message || "Network error during user creation" + ); + } +}); + +// ---------------- Edit User ---------------- +export const editUser = createAsyncThunk< + ThunkSuccess<{ user: IUserResponse }>, + { id: string; updates: Partial }, + { rejectValue: ThunkError } +>("user/editUser", async ({ id, updates }, { rejectWithValue }) => { + try { + const res = await fetch(`/api/dashboard/admin/users/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updates), + }); + + const data = await res.json(); + + if (!res.ok) { + return rejectWithValue(data.message || "Failed to update user"); + } + + return { + message: data.message || "User updated successfully", + user: data.user, + }; + } catch (err: unknown) { + return rejectWithValue( + (err as Error).message || "Network error during user update" + ); + } +}); + +// ---------------- Delete User ---------------- +export const deleteUser = createAsyncThunk< + ThunkSuccess<{ id: string }>, + string, + { rejectValue: ThunkError } +>("user/deleteUser", async (id, { rejectWithValue }) => { + try { + const res = await fetch(`/api/dashboard/admin/users/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await res.json(); + + if (!res.ok) { + return rejectWithValue(data.message || "Failed to delete user"); + } + + return { + message: data.message || "User deleted successfully", + id, + }; + } catch (err: unknown) { + return rejectWithValue( + (err as Error).message || "Network error during user deletion" + ); + } +}); + +// ---------------- Slice ---------------- +const userSlice = createSlice({ + name: "user", + initialState, + reducers: { + clearMessage: state => { + state.message = ""; + }, + clearAddedUser: state => { + state.addedUser = null; + }, + clearEditedUser: state => { + state.editedUser = null; + }, + clearError: state => { + state.error = null; + }, + }, + extraReducers: builder => { + builder + // Add User + .addCase(addUser.pending, state => { + state.status = "loading"; + state.message = "Creating user..."; + state.addedUser = null; + state.error = null; + }) + .addCase(addUser.fulfilled, (state, action) => { + state.status = "succeeded"; + state.message = action.payload.message; + state.addedUser = action.payload.user; + state.error = null; + }) + .addCase(addUser.rejected, (state, action) => { + state.status = "failed"; + state.error = action.payload as string; + state.message = action.payload as string; + state.addedUser = null; + }) + // Edit User + .addCase(editUser.pending, state => { + state.status = "loading"; + state.message = "Updating user..."; + state.editedUser = null; + state.error = null; + }) + .addCase(editUser.fulfilled, (state, action) => { + state.status = "succeeded"; + state.message = action.payload.message; + state.editedUser = action.payload.user; + state.error = null; + }) + .addCase(editUser.rejected, (state, action) => { + state.status = "failed"; + state.error = action.payload as string; + state.message = action.payload as string; + state.editedUser = null; + }) + // Delete User + .addCase(deleteUser.pending, state => { + state.status = "loading"; + state.message = "Deleting user..."; + state.error = null; + }) + .addCase(deleteUser.fulfilled, (state, action) => { + state.status = "succeeded"; + state.message = action.payload.message; + state.error = null; + }) + .addCase(deleteUser.rejected, (state, action) => { + state.status = "failed"; + state.error = action.payload as string; + state.message = action.payload as string; + }); + }, +}); + +export const { clearMessage, clearAddedUser, clearEditedUser, clearError } = + userSlice.actions; +export default userSlice.reducer;