users added

This commit is contained in:
azeeee05
2026-01-19 14:39:07 +05:30
parent 6174def815
commit 0df736d3bc
12 changed files with 1297 additions and 21 deletions
+2
View File
@@ -0,0 +1,2 @@
# Development environment
VITE_API_BASE_URL=https://dev-api.yourdomain.com
+2
View File
@@ -0,0 +1,2 @@
# Local environment
VITE_API_BASE_URL=http://localhost:8000
+2
View File
@@ -0,0 +1,2 @@
# Production environment
VITE_API_BASE_URL=https://api.yourdomain.com
+96 -17
View File
@@ -20,6 +20,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.50.0",
"react-i18next": "^16.5.3",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.12.0",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.18"
@@ -70,7 +71,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1793,7 +1793,6 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1804,7 +1803,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1864,7 +1862,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -2112,7 +2109,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2235,7 +2231,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2311,6 +2306,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2379,6 +2380,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/country-flag-icons": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz",
"integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2593,7 +2600,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3105,7 +3111,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3164,6 +3169,27 @@
"node": ">=0.8.19"
}
},
"node_modules/input-format": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=18.1.0",
"react-dom": ">=18.1.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3207,7 +3233,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -3284,8 +3309,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/leaflet-control-geocoder": {
"version": "3.3.1",
@@ -3322,6 +3346,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.34",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz",
"integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -3594,6 +3624,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3704,6 +3746,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/open-location-code": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz",
@@ -3805,7 +3856,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3851,6 +3901,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3872,7 +3933,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3882,7 +3942,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3933,6 +3992,29 @@
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-phone-number-input": {
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
"integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==",
"license": "MIT",
"dependencies": {
"classnames": "^2.5.1",
"country-flag-icons": "^1.5.17",
"input-format": "^0.3.14",
"libphonenumber-js": "^1.12.27",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-router": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
@@ -4185,7 +4267,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4280,7 +4361,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4411,7 +4491,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+4 -1
View File
@@ -4,7 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"local": "vite --mode localhost",
"dev": "vite --mode development",
"prod": "vite --mode production",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
@@ -22,6 +24,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.50.0",
"react-i18next": "^16.5.3",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.12.0",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.18"
+124
View File
@@ -0,0 +1,124 @@
import { apiClient } from "../../lib/apiClient";
import type {
ApiMessage,
User,
UserApiResponse,
UserCreateRequest,
UserStatus,
UserUpdateRequest,
UserPaginatedResponse,
} from "./UserTypes";
const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `?${query}` : "";
};
const normalizeStatus = (status?: string): UserStatus =>
status?.toLowerCase() === "inactive" ? "inactive" : "active";
const normalizeId = (value?: string | null) => {
if (value === undefined) return undefined;
if (value === null) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const toUser = (user: UserApiResponse): User => ({
id: user.id,
email: user.email,
password: "",
first_name: user.first_name,
last_name: user.last_name ?? null,
phone_number: user.phone_number ?? null,
status: normalizeStatus(user.status),
tenant_id: user.tenant_id ?? null,
tenant_name: null,
role_id: user.role_id ?? null,
role_name: null,
created_at: user.created_at,
updated_at: user.updated_at,
password_updated_at: user.password_updated_at ?? user.updated_at,
});
const toCreatePayload = (payload: UserCreateRequest) => ({
email: payload.email,
password: payload.password,
first_name: payload.first_name,
last_name: payload.last_name || undefined,
phone_number: payload.phone_number || undefined,
status: normalizeStatus(payload.status),
tenant_id: normalizeId(payload.tenant_id),
role_id: normalizeId(payload.role_id),
});
const toUpdatePayload = (payload: UserUpdateRequest) => ({
email: payload.email?.trim() || undefined,
password: payload.password,
first_name: payload.first_name?.trim() || undefined,
last_name:
payload.last_name !== undefined ? payload.last_name?.trim() || null : undefined,
phone_number:
payload.phone_number !== undefined
? payload.phone_number?.trim() || null
: undefined,
status: payload.status ? normalizeStatus(payload.status) : undefined,
tenant_id:
payload.tenant_id !== undefined ? normalizeId(payload.tenant_id) : undefined,
role_id:
payload.role_id !== undefined ? normalizeId(payload.role_id) : undefined,
});
export const usersApi = {
getAll: async () => {
const data = await apiClient.get<UserApiResponse[]>("/api/user/get");
return data.map(toUser);
},
getById: async (userId: string) => {
const data = await apiClient.get<UserApiResponse>(`/api/user/get/${userId}`);
return toUser(data);
},
create: async (payload: UserCreateRequest) => {
const data = await apiClient.post<UserApiResponse>(
"/api/user/create",
toCreatePayload(payload),
{ successMessage: "User created successfully", errorMessage: "Failed to create user" }
);
return toUser(data);
},
update: async (userId: string, payload: UserUpdateRequest) => {
const data = await apiClient.put<UserApiResponse>(
`/api/user/update/${userId}`,
toUpdatePayload(payload),
{ successMessage: "User updated successfully", errorMessage: "Failed to update user" }
);
return toUser(data);
},
remove: async (userId: string): Promise<ApiMessage> =>
apiClient.delete<ApiMessage>(`/api/user/delete/${userId}`, { successMessage: "User deleted", errorMessage: "Failed to delete user" }),
getPaginated: (params: {
page?: number;
page_size?: number;
search?: string;
status?: UserStatus | null;
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
status: params.status ?? undefined,
});
return apiClient.get<UserPaginatedResponse>(`/api/user/list${queryString}`);
},
};
+66
View File
@@ -0,0 +1,66 @@
export type UserStatus = "active" | "inactive";
export type User = {
id: string;
email: string;
password: string;
first_name: string;
last_name?: string | null;
phone_number?: string | null;
status: UserStatus;
tenant_id?: string | null;
tenant_name?: string | null;
role_id?: string | null;
role_name?: string | null;
created_at: string;
updated_at: string;
password_updated_at: string;
};
export type UserCreateRequest = {
email: string;
password: string;
first_name: string;
last_name?: string;
phone_number?: string;
status?: UserStatus;
tenant_id?: string;
role_id?: string;
};
export type UserUpdateRequest = {
email?: string;
password?: string;
first_name?: string;
last_name?: string | null;
phone_number?: string | null;
status?: UserStatus;
tenant_id?: string | null;
role_id?: string | null;
};
export type ApiMessage = {
message?: string;
};
export type UserApiResponse = {
id: string;
email: string;
first_name: string;
last_name?: string | null;
phone_number?: string | null;
status?: string;
tenant_id?: string | null;
role_id?: string | null;
created_at: string;
updated_at: string;
password_updated_at?: string | null;
};
export type UserPaginatedResponse = {
items: User[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
@@ -0,0 +1,416 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
CustomButton,
CustomBackButton,
CustomCheckBox,
CustomDropdown,
CustomInput,
} from "../../../components/custom";
import { CustomPhoneInput } from "../../../components/custom/CustomPhoneInput";
import { usersApi } from "../UserApi";
import type { UserCreateRequest, UserStatus } from "../UserTypes";
import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { rolesApi } from "../../roles/RolesApi";
import type { Role } from "../../roles/RolesTypes";
import { useAuth } from "../../../context/AuthContext";
const normalizeStatus = (value: string): UserStatus =>
value.toLowerCase() === "inactive" ? "inactive" : "active";
const AddUsers = () => {
const { t } = useTranslation(['users', 'common']);
const { user, hasAccess, isLoading: isAuthLoading } = useAuth();
const canReadAllTenants = hasAccess("superadmin.tenant.read");
const navigate = useNavigate();
const [formData, setFormData] = useState<UserCreateRequest>({
first_name: "",
last_name: "",
email: "",
password: "",
phone_number: "",
tenant_id: "",
role_id: "",
status: "active",
});
const [isSuperAdminUser, setIsSuperAdminUser] = useState(false);
const [lastTenantId, setLastTenantId] = useState("");
const [tenants, setTenants] = useState<Tenant[]>([]);
const [currentTenantName, setCurrentTenantName] = useState("");
const [roles, setRoles] = useState<Role[]>([]);
const [isTenantLoading, setIsTenantLoading] = useState(false);
const [isRoleLoading, setIsRoleLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [loadError, setLoadError] = useState("");
useEffect(() => {
if (isAuthLoading) {
return;
}
let isMounted = true;
const loadTenants = async () => {
if (!canReadAllTenants) {
setIsTenantLoading(true);
try {
if (user?.tenant_id && user?.tenant_name) {
if (isMounted) {
setCurrentTenantName(user.tenant_name);
setTenants([]);
setFormData((prev) => ({
...prev,
tenant_id: user.tenant_id ?? "",
}));
}
return;
}
const data = await tenantsApi.getMine();
if (isMounted) {
setCurrentTenantName(data.tenant_name);
setTenants([]);
setFormData((prev) => ({
...prev,
tenant_id: data.id,
}));
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load tenant.";
setLoadError((prev) => prev || message);
setFormData((prev) => ({
...prev,
tenant_id: user?.tenant_id ?? "",
}));
}
} finally {
if (isMounted) {
setIsTenantLoading(false);
}
}
return;
}
setIsTenantLoading(true);
try {
const data = await tenantsApi.getAll();
if (isMounted) {
setTenants(data);
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load tenants.";
setLoadError((prev) => prev || message);
}
} finally {
if (isMounted) {
setIsTenantLoading(false);
}
}
};
const loadRoles = async () => {
setIsRoleLoading(true);
try {
const data = await rolesApi.getAll();
if (isMounted) {
setRoles(data);
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error ? error.message : "Unable to load roles.";
setLoadError((prev) => prev || message);
}
} finally {
if (isMounted) {
setIsRoleLoading(false);
}
}
};
loadTenants();
loadRoles();
return () => {
isMounted = false;
};
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
const isRoleAllowedForTenant = useCallback(
(role: Role, tenantId?: string, forceSuperAdmin?: boolean) => {
const isSuperAdminMode = forceSuperAdmin ?? isSuperAdminUser;
if (isSuperAdminMode) {
return !role.tenant_id;
}
if (canReadAllTenants) {
if (!tenantId) {
return false;
}
return role.tenant_id === tenantId;
}
if (tenantId) {
return !role.tenant_id || role.tenant_id === tenantId;
}
return !role.tenant_id;
},
[canReadAllTenants, isSuperAdminUser]
);
const filteredRoles = useMemo(
() =>
roles.filter((role) =>
isRoleAllowedForTenant(role, formData.tenant_id)
),
[isRoleAllowedForTenant, roles, formData.tenant_id]
);
const handleChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handlePhoneChange = (value: string | undefined) => {
setFormData((prev) => ({ ...prev, phone_number: value || "" }));
};
const handleRoleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = event.target;
setFormData((prev) => ({
...prev,
role_id: value || "",
}));
};
const handleTenantChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const tenantId = event.target.value;
setLastTenantId(tenantId);
setFormData((prev) => {
const roleStillValid = roles.some(
(role) =>
role.id === prev.role_id && isRoleAllowedForTenant(role, tenantId)
);
return {
...prev,
tenant_id: tenantId || "",
role_id: roleStillValid ? prev.role_id : "",
};
});
};
const handleSuperAdminToggle = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const checked = event.target.checked;
setIsSuperAdminUser(checked);
if (checked) {
setLastTenantId(formData.tenant_id || "");
}
setFormData((prev) => {
const nextTenantId = checked ? "" : lastTenantId || "";
const roleStillValid = roles.some(
(role) =>
role.id === prev.role_id &&
isRoleAllowedForTenant(role, nextTenantId, checked)
);
return {
...prev,
tenant_id: nextTenantId,
role_id: roleStillValid ? prev.role_id : "",
};
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage("");
setIsLoading(true);
try {
const statusValue = formData.status?.trim() || "active";
const payload: UserCreateRequest = {
first_name: formData.first_name.trim(),
last_name: formData.last_name?.trim() || undefined,
email: formData.email.trim(),
password: formData.password,
phone_number: formData.phone_number?.trim() || undefined,
tenant_id: formData.tenant_id?.trim() || undefined,
role_id: formData.role_id?.trim() || undefined,
status: normalizeStatus(statusValue),
};
await usersApi.create(payload);
navigate("/users");
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to add user.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<CustomBackButton to="/users" tooltip={t('backToUsers')} />
<div>
<h1 className="text-2xl font-semibold text-(--text-primary)">{t('add')}</h1>
<p className="text-sm text-(--text-secondary)">
{t('subTitle')}
</p>
</div>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-(--card-border) bg-(--card-bg) p-6 shadow-sm"
>
{canReadAllTenants && (
<div className="flex items-center justify-end gap-3">
<CustomCheckBox
label={t('fields.superAdminUser')}
checked={isSuperAdminUser}
onChange={handleSuperAdminToggle}
/>
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label={t('fields.firstName')}
name="first_name"
placeholder={t('fields.firstNamePlaceholder')}
value={formData.first_name}
onChange={handleChange}
required
/>
<CustomInput
label={t('fields.lastName')}
name="last_name"
placeholder={t('fields.lastNamePlaceholder')}
value={formData.last_name}
onChange={handleChange}
/>
<CustomInput
label={t('fields.email')}
name="email"
type="email"
placeholder={t('fields.emailPlaceholder')}
value={formData.email}
onChange={handleChange}
required
/>
<CustomPhoneInput
label={t('fields.phone')}
name="phone_number"
placeholder={t('fields.phonePlaceholder')}
value={formData.phone_number}
onChange={handlePhoneChange}
required
defaultCountry="IN"
/>
{canReadAllTenants ? (
isSuperAdminUser ? (
<CustomInput
label={t('fields.tenant')}
name="tenant_name"
value={t('fields.superAdminNoTenant')}
disabled
readOnly
/>
) : (
<CustomDropdown
label={t('fields.tenant')}
name="tenant_id"
placeholder={
isTenantLoading ? t('fields.tenantLoading') : t('fields.tenantPlaceholder')
}
value={formData.tenant_id ?? ""}
onChange={handleTenantChange}
options={tenants.map((tenant) => ({
label: tenant.tenant_name,
value: tenant.id,
}))}
disabled={isTenantLoading}
/>
)
) : (
<CustomInput
label={t('fields.tenant')}
name="tenant_name"
value={currentTenantName || user?.tenant_name || user?.tenant_id || ""}
placeholder={
isTenantLoading ? t('fields.tenantLoading') : t('fields.assignedTenant')
}
disabled
readOnly
/>
)}
<CustomDropdown
label={t('fields.role')}
name="role_id"
placeholder={
isSuperAdminUser
? isRoleLoading
? t('fields.roleLoading')
: t('fields.roleSuperAdminPlaceholder')
: formData.tenant_id
? isRoleLoading
? t('fields.roleLoading')
: t('fields.rolePlaceholder')
: t('fields.roleFilterPlaceholder')
}
value={formData.role_id ?? ""}
onChange={handleRoleChange}
options={filteredRoles.map((role) => ({
label: role.role_name,
value: role.id,
}))}
disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)}
/>
<CustomInput
label={t('fields.password')}
name="password"
type="password"
placeholder={t('fields.passwordPlaceholder')}
value={formData.password}
onChange={handleChange}
required
/>
</div>
{(errorMessage || loadError) && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm text-red-600 dark:text-red-400">
{errorMessage || loadError}
</div>
)}
<div className="flex justify-end">
<CustomButton type="submit" variant="primary" loading={isLoading}>
{t('actions.create')}
</CustomButton>
</div>
</form>
</div>
);
};
export default AddUsers;
@@ -0,0 +1,561 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Edit2, Eye, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
CustomButton,
CustomCheckBox,
CustomConfirmationModal,
CustomDropdown,
CustomInput,
CustomModal,
CustomTable,
CustomLoader,
CustomActionMenu,
CustomActionItem,
CustomStatus,
} from "../../../components/custom";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import type {
User,
UserStatus,
UserUpdateRequest,
UserPaginatedResponse,
} from "../UserTypes";
import { usersApi } from "../UserApi";
import { rolesApi } from "../../roles/RolesApi";
import type { Role } from "../../roles/RolesTypes";
import { useAuth } from "../../../context/AuthContext";
import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { useDebounce } from "../../../components/hooks/useDebounce";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
const formatName = (firstName: string, lastName?: string | null) =>
[firstName, lastName].filter(Boolean).join(" ");
const formatDate = (dateString?: string | null, language: string = 'en') => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
const hasTime = dateString.includes("T") || dateString.includes(":");
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
if (hasTime) {
options.hour = "2-digit";
options.minute = "2-digit";
options.hour12 = false;
}
return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options);
} catch {
return dateString;
}
};
type EditFormState = {
first_name: string;
last_name: string;
email: string;
phone_number: string;
tenant_id: string;
role_id: string;
status: UserStatus;
password: string;
};
const emptyEditForm: EditFormState = {
first_name: "",
last_name: "",
email: "",
phone_number: "",
tenant_id: "",
role_id: "",
status: "active",
password: "",
};
const AllUsers = () => {
const { t, i18n } = useTranslation(['users', 'common']);
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
// State
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
// Pagination & filters
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<UserStatus | null>(null);
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
// Modals
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [editForm, setEditForm] = useState<EditFormState>(emptyEditForm);
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Supporting data
const [roles, setRoles] = useState<Role[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [isRoleLoading, setIsRoleLoading] = useState(false);
useEffect(() => {
let isMounted = true;
const loadSupportingData = async () => {
setIsRoleLoading(true);
try {
const rolesData = await rolesApi.getAll();
if (isMounted) setRoles(rolesData);
if (hasAccess("superadmin.tenant.read")) {
const tenantsData = await tenantsApi.getAll();
if (isMounted) setTenants(tenantsData);
} else if (currentUser?.tenant_id) {
const tenant = await tenantsApi.getMine();
if (isMounted) setCurrentTenant(tenant);
}
} catch (err) {
console.error("Failed to load roles/tenants:", err);
} finally {
if (isMounted) setIsRoleLoading(false);
}
};
const loadUsers = async () => {
setIsLoading(true);
setErrorMessage("");
try {
const response: UserPaginatedResponse = await usersApi.getPaginated({
page,
page_size: pageSize,
search: debouncedSearch || undefined,
status: statusFilter,
});
if (isMounted) {
setUsers(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} catch (err) {
if (isMounted) {
const msg = err instanceof Error ? err.message : "Unable to load users.";
setErrorMessage(msg);
}
} finally {
if (isMounted) setIsLoading(false);
}
};
if (!isAuthLoading) {
loadSupportingData();
loadUsers();
}
return () => {
isMounted = false;
};
}, [
isAuthLoading,
hasAccess,
currentUser?.tenant_id,
page,
pageSize,
debouncedSearch,
statusFilter,
]);
// Reset page on search/filter change
useEffect(() => {
setPage(1);
}, [debouncedSearch, statusFilter]);
// Restore search focus
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
const getTenantName = useCallback(
(tenantId?: string | null) => {
if (!tenantId) return "";
const found = tenants.find((t) => t.id === tenantId);
if (found) return found.tenant_name;
if (currentTenant?.id === tenantId) return currentTenant.tenant_name;
return tenantId;
},
[tenants, currentTenant]
);
const getRoleName = useCallback(
(roleId?: string | null) => {
if (!roleId) return "";
return roles.find((r) => r.id === roleId)?.role_name || roleId;
},
[roles]
);
const openView = useCallback((user: User) => {
setSelectedUser(user);
setIsViewOpen(true);
}, []);
const openEdit = useCallback(
(user: User) => {
const resolvedTenantId = currentUser?.tenant_id ?? user.tenant_id ?? "";
setSelectedUser(user);
setEditForm({
first_name: user.first_name,
last_name: user.last_name ?? "",
email: user.email,
phone_number: user.phone_number ?? "",
tenant_id: resolvedTenantId,
role_id: user.role_id ?? "",
status: user.status,
password: "",
});
setEditError("");
setIsEditOpen(true);
},
[currentUser?.tenant_id]
);
const openDelete = useCallback((user: User) => {
setSelectedUser(user);
setDeleteError("");
setIsDeleteOpen(true);
}, []);
const closeView = useCallback(() => {
setIsViewOpen(false);
setSelectedUser(null);
}, []);
const closeEdit = useCallback(() => {
setIsEditOpen(false);
setSelectedUser(null);
setEditError("");
}, []);
const closeDelete = useCallback(() => {
setIsDeleteOpen(false);
setSelectedUser(null);
setDeleteError("");
}, []);
const handleEditChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setEditForm((prev) => ({ ...prev, [name]: value }));
},
[]
);
const handleStatusChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEditForm((prev) => ({
...prev,
status: e.target.checked ? "active" : "inactive",
}));
},
[]
);
const filteredRoles = useMemo(
() =>
roles.filter((role) => {
if (editForm.tenant_id) {
return !role.tenant_id || role.tenant_id === editForm.tenant_id;
}
return true;
}),
[roles, editForm.tenant_id]
);
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
setIsSaving(true);
setEditError("");
try {
const payload: UserUpdateRequest = {
first_name: editForm.first_name.trim(),
last_name: editForm.last_name.trim() || null,
email: editForm.email.trim(),
phone_number: editForm.phone_number.trim() || null,
status: editForm.status,
role_id: editForm.role_id || null,
};
if (editForm.password.trim()) {
payload.password = editForm.password.trim();
}
const updated = await usersApi.update(selectedUser.id, payload);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
closeEdit();
} catch (err) {
setEditError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedUser) return;
setIsDeleting(true);
try {
await usersApi.remove(selectedUser.id);
setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id));
closeDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Failed to delete user");
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<Array<ColumnDef<User>>>(
() => [
{
key: "first_name",
header: t('columns.name'),
render: (row) => formatName(row.first_name, row.last_name),
},
{ key: "email", header: t('columns.email') },
{
key: "phone_number",
header: t('columns.phone'),
render: (row) => row.phone_number || "",
},
{
key: "status",
header: t('columns.status'),
searchable: false,
render: (row) => <CustomStatus status={row.status} />,
},
{
key: "tenant_id",
header: t('columns.tenant'),
render: (row) => getTenantName(row.tenant_id),
},
{
key: "role_id",
header: t('columns.role'),
render: (row) => getRoleName(row.role_id),
},
{
key: "id",
header: t('columns.actions'),
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.user.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.user.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.user.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
},
],
[getTenantName, getRoleName, openView, openEdit, openDelete, t]
);
const filterControls = (
<select
value={statusFilter === null ? "all" : statusFilter}
onChange={(e) => {
const val = e.target.value as "all" | "active" | "inactive";
setStatusFilter(val === "all" ? null : val);
}}
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
>
<option value="all" className="bg-(--card-bg) text-(--text-primary)">
{t('common:common.allStatuses', { defaultValue: 'All Statuses' })}
</option>
<option value="active" className="bg-(--card-bg) text-(--text-primary)">
{t('common:status.active')}
</option>
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">
{t('common:status.inactive')}
</option>
</select>
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
</div>
<ProtectedComponent requiredAccess="admin.user.create">
<Link to="/users/add">
<CustomButton variant="primary">+ {t('add')}</CustomButton>
</Link>
</ProtectedComponent>
</div>
{isLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
<CustomLoader />
</div>
) : errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
</div>
) : (
<CustomTable
data={users}
columns={columns}
getRowId={(row) => row.id}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
filterControls={filterControls}
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title={t('details')}
size="lg"
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
>
{selectedUser ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.name')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{formatName(selectedUser.first_name, selectedUser.last_name)}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.email')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.email}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.phone')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.phone_number || "--"}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.status')}</p><CustomStatus status={selectedUser.status} /></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedUser.tenant_id)}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.role')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getRoleName(selectedUser.role_id)}</p></div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedUser.created_at, i18n.language)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedUser.updated_at, i18n.language)}
</p>
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">{t('common:common.noData')}</p>
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title={t('edit')}
size="lg"
footer={
<>
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
<CustomButton type="submit" form="edit-user-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
</>
}
>
<form id="edit-user-form" onSubmit={handleUpdate} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput label={t('fields.firstName')} name="first_name" value={editForm.first_name} onChange={handleEditChange} required />
<CustomInput label={t('fields.lastName')} name="last_name" value={editForm.last_name} onChange={handleEditChange} />
<CustomInput label={t('fields.email')} name="email" type="email" value={editForm.email} onChange={handleEditChange} required />
<CustomInput label={t('fields.phone')} name="phone_number" type="tel" maxLength={10} phonePrefix="+91" value={editForm.phone_number} onChange={handleEditChange} />
<CustomInput label={t('fields.tenant')} value={getTenantName(currentUser?.tenant_id ?? editForm.tenant_id) || "--"} disabled readOnly />
<CustomDropdown
label={t('fields.role')}
name="role_id"
value={editForm.role_id}
onChange={handleEditChange}
options={filteredRoles.map((r) => ({ label: r.role_name, value: r.id }))}
placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')}
disabled={isRoleLoading || filteredRoles.length === 0}
/>
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
<div className="flex items-center">
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
</div>
</div>
{editError && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">{editError}</div>}
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
onConfirm={handleDelete}
title={t('messages.deleteTitle')}
description={deleteError || t('messages.deleteConfirm')}
confirmText={t('actions.delete')}
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllUsers;
+14
View File
@@ -0,0 +1,14 @@
import { Route, Routes } from "react-router-dom";
import AllUsers from "./components/AllUsers";
import AddUsers from "./components/AddUsers";
const Users: React.FC = () => {
return (
<Routes>
<Route index element={<AllUsers />} />
<Route path="add" element={<AddUsers />} />
</Routes>
);
};
export default Users;
+2
View File
@@ -10,6 +10,7 @@ import Tenants from "../application/tenants";
import ProtectedRoutes from "./ProtectedRoutes";
import Theme from "../application/theme";
import SettingsPage from "../application/settings/SettingsPage";
import Users from "../application/users";
const AppRoutes = () => {
return (
<Routes>
@@ -26,6 +27,7 @@ const AppRoutes = () => {
<Route path="/roles/*" element={<Roles />} />
<Route path="/theme/*" element={<Theme />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/users/*" element={<Users />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
+7 -2
View File
@@ -1,8 +1,13 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
console.log(`\n 🔌 Backend API URL: \x1b[36m${env.VITE_API_BASE_URL}\x1b[0m\n`);
return {
plugins: [react(), tailwindcss()],
}
})