Added New User Registration Detail Modal

This commit is contained in:
Mitchell Magro 2025-10-28 16:42:25 +01:00
parent fe6ed86a76
commit be1df88e75
15 changed files with 385 additions and 210 deletions

View File

@ -1,9 +1,9 @@
import Users from "@/app/features/Pages/Admin/Users/users";
export default async function BackOfficeUsersPage() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
? `${process.env.NEXT_PUBLIC_BASE_URL}`
: "http://localhost:4000";
// const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
// ? `${process.env.NEXT_PUBLIC_BASE_URL}`
// : "http://localhost:4000";
// const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
// cache: "no-store", // 👈 disables caching for SSR freshness
// });

View File

@ -3,9 +3,11 @@ import React, { useState } from "react";
import { Card, CardContent, Typography, Stack } from "@mui/material";
import { IUser } from "./interfaces";
import UserTopBar from "@/app/features/UserRoles/AddUser/AddUserButton";
import Modal from "@/app/components/Modal/Modal";
import UserRoleCard from "@/app/features/UserRoles/userRoleCard";
import AddUser from "@/app/features/UserRoles/AddUser/AddUser";
import { setSidebarOpen } from "@/app/redux/ui/uiSlice";
import { AppDispatch } from "@/app/redux/types";
import { useDispatch } from "react-redux";
interface UsersProps {
users: IUser[];
@ -13,37 +15,38 @@ interface UsersProps {
const Users: React.FC<UsersProps> = ({ users }) => {
const [showAddUser, setShowAddUser] = useState(false);
const dispatch = useDispatch<AppDispatch>();
return (
<div>
<UserTopBar onAddUser={() => setShowAddUser(true)} />
{users.map((user: IUser) => (
<Card key={user.id} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6">{user.username}</Typography>
<Typography variant="body2">
Merchant ID: {user.merchantId}
</Typography>
<UserTopBar
onAddUser={() => {
setShowAddUser(true);
dispatch(setSidebarOpen(false));
}}
/>
{users?.length > 0 &&
users.map((user: IUser) => (
<Card key={user.id} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6">{user.username}</Typography>
<Typography variant="body2">
Merchant ID: {user.merchantId}
</Typography>
<Stack direction="row" spacing={1} mt={1}>
<UserRoleCard
user={user}
isAdmin={true}
lastLogin="small"
merchants={[]} // merchants={Numberuser.allowedMerchantIds}
/>
</Stack>
</CardContent>
</Card>
))}
<Stack direction="row" spacing={1} mt={1}>
<UserRoleCard
user={user}
isAdmin={true}
lastLogin="small"
merchants={[]} // merchants={Numberuser.allowedMerchantIds}
/>
</Stack>
</CardContent>
</Card>
))}
<Modal
open={showAddUser}
onClose={() => setShowAddUser(false)}
title="Add User"
>
<AddUser />
</Modal>
<AddUser open={showAddUser} onClose={() => setShowAddUser(false)} />
</div>
);
};

View File

@ -11,18 +11,20 @@ import { COUNTRY_CODES } from "../constants";
import { formatPhoneDisplay, validatePhone } from "../utils";
import Spinner from "../../../components/Spinner/Spinner";
import { RootState } from "@/app/redux/store";
import toast from "react-hot-toast";
import Modal from "@/app/components/Modal/Modal";
interface AddUserFormProps {
onSuccess?: () => void;
interface AddUserProps {
open: boolean;
onClose: () => void;
}
const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
const router = useRouter();
const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
// const router = useRouter();
const dispatch = useDispatch<AppDispatch>();
const { status, error: authError } = useSelector(
(state: RootState) => state.auth
);
const [form, setForm] = useState<IEditUserForm>({
username: "",
firstName: "",
@ -108,9 +110,14 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
};
try {
await dispatch(addUser(formattedForm));
if (onSuccess) onSuccess();
router.refresh(); // <- refreshes the page (SSR re-runs)
const resultAction = await dispatch(addUser(formattedForm));
const result = addUser.fulfilled.match(resultAction);
if (result && resultAction.payload.success) {
toast.success(resultAction.payload.message);
// router.refresh();
onClose();
}
} catch (err) {
// Error is handled by Redux state
console.error("Failed to add user:", err);
@ -118,173 +125,175 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
};
return (
<form className="add-user" onSubmit={handleSubmit}>
<input
name="username"
placeholder="Username"
value={form.username}
onChange={handleChange}
required
/>
<input
name="firstName"
placeholder="First Name"
value={form.firstName}
onChange={handleChange}
required
/>
<input
name="lastName"
placeholder="Last Name"
value={form.lastName}
onChange={handleChange}
required
/>
<input
name="email"
type="email"
placeholder="Email"
value={form.email}
onChange={handleChange}
required
/>
<div className="array-field-container">
<label>Merchants:</label>
<select
name="merchants"
value=""
<Modal open={open} onClose={onClose} title="Add User">
<form className="add-user" onSubmit={handleSubmit}>
<input
name="username"
placeholder="Username"
value={form.username}
onChange={handleChange}
className="add-user__select"
>
<option value="">Select Merchant</option>
<option value="Win Bot">Win Bot</option>
<option value="Data Spin">Data Spin</option>
</select>
{form.merchants.length > 0 && (
<div className="selected-items">
{form.merchants.map((merchant, index) => (
<span key={index} className="selected-item">
{merchant}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
merchants: prev.merchants.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
))}
</div>
)}
</div>
required
/>
<input
name="firstName"
placeholder="First Name"
value={form.firstName}
onChange={handleChange}
required
/>
<input
name="lastName"
placeholder="Last Name"
value={form.lastName}
onChange={handleChange}
required
/>
<input
name="email"
type="email"
placeholder="Email"
value={form.email}
onChange={handleChange}
required
/>
<div className="array-field-container">
<label>Merchants:</label>
<select
name="merchants"
value=""
onChange={handleChange}
className="add-user__select"
>
<option value="">Select Merchant</option>
<option value="Win Bot">Win Bot</option>
<option value="Data Spin">Data Spin</option>
</select>
{form.merchants.length > 0 && (
<div className="selected-items">
{form.merchants.map((merchant, index) => (
<span key={index} className="selected-item">
{merchant}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
merchants: prev.merchants.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div className="array-field-container">
<label>Groups:</label>
<div className="array-field-container">
<label>Groups:</label>
<select
name="groups"
value=""
onChange={handleChange}
className="add-user__select"
>
<option value="">Select Group</option>
<option value="Admin">Admin</option>
<option value="Reader">Reader</option>
<option value="Manager">Manager</option>
<option value="User">User</option>
</select>
{form.groups.length > 0 && (
<div className="selected-items">
{form.groups.map((group, index) => (
<span key={index} className="selected-item">
{group}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
groups: prev.groups.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
))}
</div>
)}
</div>
<select
name="groups"
value=""
name="jobTitle"
value={form.jobTitle}
onChange={handleChange}
required
className="add-user__select"
>
<option value="">Select Group</option>
<option value="">Select Job Title</option>
<option value="Admin">Admin</option>
<option value="Reader">Reader</option>
<option value="Manager">Manager</option>
<option value="User">User</option>
<option value="Manager">Manager</option>
<option value="Supervisor">Supervisor</option>
<option value="Director">Director</option>
</select>
{form.groups.length > 0 && (
<div className="selected-items">
{form.groups.map((group, index) => (
<span key={index} className="selected-item">
{group}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
groups: prev.groups.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
<div className="phone-input-container">
<select
name="countryCode"
value={countryCode}
onChange={handleCountryCodeChange}
className="country-code-select"
>
{COUNTRY_CODES.map(country => (
<option key={country.code} value={country.code}>
{country.flag} {country.code} {country.country}
</option>
))}
</div>
</select>
<input
name="phone"
type="tel"
placeholder="Phone number (optional)"
value={form.phone}
onChange={handleChange}
className={phoneError ? "phone-input-error" : ""}
/>
</div>
{authError && (
<span style={{ color: "red", width: "100%" }}>{authError}</span>
)}
</div>
<select
name="jobTitle"
value={form.jobTitle}
onChange={handleChange}
required
className="add-user__select"
>
<option value="">Select Job Title</option>
<option value="Admin">Admin</option>
<option value="Reader">Reader</option>
<option value="User">User</option>
<option value="Manager">Manager</option>
<option value="Supervisor">Supervisor</option>
<option value="Director">Director</option>
</select>
<div className="phone-input-container">
<select
name="countryCode"
value={countryCode}
onChange={handleCountryCodeChange}
className="country-code-select"
<span
className="phone-error-message"
style={{
visibility: phoneError ? "visible" : "hidden",
color: "red",
width: "100%",
}}
>
{COUNTRY_CODES.map(country => (
<option key={country.code} value={country.code}>
{country.flag} {country.code} {country.country}
</option>
))}
</select>
<input
name="phone"
type="tel"
placeholder="Phone number (optional)"
value={form.phone}
onChange={handleChange}
className={phoneError ? "phone-input-error" : ""}
/>
</div>
{phoneError}
</span>
{authError && (
<span style={{ color: "red", width: "100%" }}>{authError}</span>
)}
<span
className="phone-error-message"
style={{
visibility: phoneError ? "visible" : "hidden",
color: "red",
width: "100%",
}}
>
{phoneError}
</span>
<div className="add-user__button-container">
<button type="submit" disabled={loading}>
{loading ? (
<>
<Spinner size="small" color="#fff" />
Adding...
</>
) : (
"Add User"
)}
</button>
</div>
</form>
<div className="add-user__button-container">
<button type="submit" disabled={loading}>
{loading ? (
<>
<Spinner size="small" color="#fff" />
Adding...
</>
) : (
"Add User"
)}
</button>
</div>
</form>
</Modal>
);
};
export default AddUserForm;
export default AddUser;

View File

@ -0,0 +1,63 @@
.new-user__content {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem 0;
.new-user__row {
display: flex;
flex-direction: column;
gap: 0.4rem;
.new-user__label {
font-weight: 600;
color: #444;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.new-user__value {
background: #f7f7f8;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.75rem 1rem;
font-size: 0.95rem;
color: #222;
word-break: break-word;
code {
background: #272822;
color: #f8f8f2;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: "Source Code Pro", monospace;
font-size: 0.9rem;
}
}
}
}
/* Optional: Add a subtle hover highlight for readability */
.new-user__row:hover .new-user__value {
border-color: #bdbdbd;
background: #fafafa;
}
/* Responsive tweak for smaller screens */
@media (max-width: 600px) {
.new-user__content {
gap: 1rem;
}
.new-user__row {
.new-user__label {
font-size: 0.85rem;
}
.new-user__value {
font-size: 0.9rem;
padding: 0.6rem 0.8rem;
}
}
}

View File

@ -0,0 +1,43 @@
"use client";
import React from "react";
import Modal from "@/app/components/Modal/Modal";
import { useDispatch, useSelector } from "react-redux";
import { clearAddedUser } from "@/app/redux/auth/authSlice";
import { AppDispatch } from "@/app/redux/types";
import { useRouter } from "next/navigation";
import { selectAddedUser } from "@/app/redux/auth/selectors";
import { setShowNewUserModal } from "@/app/redux/ui/uiSlice";
import "./NewUser.scss";
const NewUser: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const router = useRouter();
const user = useSelector(selectAddedUser);
const handleClose = () => {
dispatch(clearAddedUser());
dispatch(setShowNewUserModal(false));
// Refresh data after closing to update lists without racing rehydrate
router.refresh();
};
return (
<Modal open={true} onClose={handleClose} title="New user Details">
<div className="new-user__content">
<div className="new-user__row">
<label className="new-user__label">Email</label>
<div className="new-user__value">{user?.email}</div>
</div>
<div className="new-user__row">
<label className="new-user__label">Temporary Password</label>
<div className="new-user__value">
<code>{user?.password}</code>
</div>
</div>
</div>
</Modal>
);
};
export default NewUser;

View File

@ -1,6 +1,6 @@
import { RootState } from "@/app/redux/types";
import React from "react";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/types";
interface MainContentProps {
children: React.ReactNode;

View File

@ -5,6 +5,7 @@ import ReduxProvider from "./ReduxProvider"; // moved into app/
import { AuthBootstrap } from "./AuthBootstrap"; // moved into app/
import { Toaster } from "react-hot-toast";
import "../styles/globals.scss";
import Modals from "./modals";
export const metadata: Metadata = {
title: "Your App",
@ -21,6 +22,7 @@ export default function RootLayout({
<body>
<ReduxProvider>
{/* Bootstraps session validation + redirect if invalid */}
<Modals />
<AuthBootstrap />
<ThemeRegistry>{children}</ThemeRegistry>
<Toaster position="top-right" reverseOrder={false} />

14
app/modals.tsx Normal file
View File

@ -0,0 +1,14 @@
"use client";
import React from "react";
import NewUser from "./features/UserRoles/NewUser/NewUser";
import { useSelector } from "react-redux";
// import { selectIsLoggedIn } from "./redux/auth/selectors";
import { selectShowNewUserModal } from "./redux/ui/selectors";
const Modals: React.FC = () => {
// const isLoggedIn = useSelector(selectIsLoggedIn);
const showNewUserModal = useSelector(selectShowNewUserModal);
return <>{showNewUserModal && <NewUser />}</>;
};
export default Modals;

View File

@ -18,6 +18,7 @@ interface AuthState {
user: IUserResponse | null;
tokenInfo: TokenInfo | null;
mustChangePassword: boolean;
addedUser: IUserResponse | null;
}
const initialState: AuthState = {
@ -28,6 +29,7 @@ const initialState: AuthState = {
user: null,
tokenInfo: null,
mustChangePassword: false,
addedUser: null,
};
// ---------------- Login ----------------
@ -179,7 +181,7 @@ export const validateAuth = createAsyncThunk<
// ---------------- Add User ----------------
export const addUser = createAsyncThunk<
ThunkSuccess<{ user: IUserResponse }>,
ThunkSuccess<{ user: IUserResponse; success: boolean }>,
IEditUserForm,
{ rejectValue: ThunkError; state: RootState }
>("auth/addUser", async (userData, { rejectWithValue, getState }) => {
@ -195,14 +197,17 @@ export const addUser = createAsyncThunk<
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"
@ -253,6 +258,9 @@ const authSlice = createSlice({
clearAuthMessage: state => {
state.authMessage = "";
},
clearAddedUser: state => {
state.addedUser = null;
},
},
extraReducers: builder => {
builder
@ -277,12 +285,11 @@ const authSlice = createSlice({
})
// Logout
.addCase(logout.fulfilled, (state, action) => {
state.status = "succeeded";
state.isLoggedIn = false;
state.authMessage = action.payload.message;
state.user = null;
state.tokenInfo = null;
state.mustChangePassword = false;
return {
...initialState,
status: "succeeded",
authMessage: action.payload.message,
};
})
.addCase(logout.rejected, (state, action) => {
state.status = "failed";
@ -291,12 +298,12 @@ const authSlice = createSlice({
})
// Auto Logout
.addCase(autoLogout.fulfilled, (state, action) => {
state.status = "succeeded";
state.isLoggedIn = false;
state.authMessage = action.payload.message;
state.user = null;
state.tokenInfo = null;
state.mustChangePassword = false;
return {
...initialState,
status: "succeeded",
isLoggedIn: false,
authMessage: action.payload.message,
};
})
.addCase(autoLogout.rejected, (state, action) => {
state.status = "failed";
@ -336,15 +343,18 @@ const authSlice = createSlice({
.addCase(addUser.pending, state => {
state.status = "loading";
state.authMessage = "Creating user...";
state.addedUser = null;
})
.addCase(addUser.fulfilled, (state, action) => {
state.status = "succeeded";
state.authMessage = action.payload.message;
state.addedUser = action.payload.user;
})
.addCase(addUser.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.authMessage = action.payload as string;
state.addedUser = null;
})
// Update User Details
.addCase(updateUserDetails.pending, state => {
@ -364,5 +374,6 @@ const authSlice = createSlice({
},
});
export const { setAuthMessage, clearAuthMessage } = authSlice.actions;
export const { setAuthMessage, clearAuthMessage, clearAddedUser } =
authSlice.actions;
export default authSlice.reducer;

View File

@ -12,3 +12,4 @@ export const selectTimeUntilExpiration = (state: RootState) =>
state.auth?.tokenInfo?.timeUntilExpiration || 0;
export const selectExpiresInHours = (state: RootState) =>
state.auth?.tokenInfo?.expiresInHours || 0;
export const selectAddedUser = (state: RootState) => state.auth?.addedUser;

View File

@ -11,6 +11,7 @@ import authReducer from "./auth/authSlice";
import uiReducer from "./ui/uiSlice";
import userEpics from "./user/epic";
import authEpics from "./auth/epic";
import uiEpics from "./ui/epic";
type PersistedAuth = { user: unknown | null };
@ -40,7 +41,7 @@ const rootReducer = combineReducers({
ui: uiReducer,
});
const rootEpic = combineEpics(...userEpics, ...authEpics);
const rootEpic = combineEpics(...userEpics, ...authEpics, ...uiEpics);
const persistedReducer = persistReducer(persistConfig, rootReducer as Reducer);

View File

@ -8,6 +8,7 @@ export type ThunkSuccess<T extends object = object> = { message: string } & T;
export type ThunkError = string;
export interface IUserResponse {
success: boolean;
token: string;
id: string;
name: string | null;
@ -19,6 +20,7 @@ export interface IUserResponse {
phone?: string | null;
jobTitle?: string | null;
enabled?: boolean;
password?: string;
authorities?: string[];
allowedMerchantIds?: number[];
created?: string;
@ -40,5 +42,5 @@ export interface ILoginResponse {
user: IUserResponse;
success: boolean;
message: string;
must_change_password: boolean;
must_change_password?: boolean;
}

16
app/redux/ui/epic.ts Normal file
View File

@ -0,0 +1,16 @@
import { Epic } from "redux-observable";
import { filter, map } from "rxjs/operators";
import { addUser } from "../auth/authSlice";
import { setShowNewUserModal } from "./uiSlice";
export const logoutRedirectEpic: Epic = action$ =>
action$.pipe(
filter(addUser.fulfilled.match),
map(() => {
return setShowNewUserModal(true);
})
);
const authEpics = [logoutRedirectEpic];
export default authEpics;

View File

@ -0,0 +1,4 @@
import { RootState } from "../types";
export const selectShowNewUserModal = (state: RootState) =>
state.ui.showNewUserModal;

View File

@ -2,10 +2,12 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface UIState {
sidebarOpen: boolean;
showNewUserModal: boolean;
}
const initialState: UIState = {
sidebarOpen: true,
showNewUserModal: false,
};
const uiSlice = createSlice({
@ -18,8 +20,12 @@ const uiSlice = createSlice({
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
state.sidebarOpen = action.payload;
},
setShowNewUserModal: (state, action: PayloadAction<boolean>) => {
state.showNewUserModal = action.payload;
},
},
});
export const { toggleSidebar, setSidebarOpen } = uiSlice.actions;
export const { toggleSidebar, setSidebarOpen, setShowNewUserModal } =
uiSlice.actions;
export default uiSlice.reducer;