Merge pull request 'azeem' (#1) from azeem into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/1
This commit is contained in:
azeem
2026-01-19 07:05:11 +00:00
26 changed files with 3019 additions and 186 deletions
+527 -152
View File
@@ -1,192 +1,567 @@
import React, { useEffect, useState } from "react";
import { toast } from "react-toastify";
import React, { useState, useEffect } from "react";
import { useAuth } from "../../context/AuthContext";
import { CustomLoader, CustomModal, CustomInput, CustomButton } from "../../components/custom";
import { useTranslation } from "react-i18next";
import { authApi } from "../authentication/AuthApi";
import { Key, Pencil } from "lucide-react";
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
import { useTheme } from "../../context/ThemeContext";
import { authApi } from "../Authentication/AuthApi";
import { paletteApi } from "../theme/PaletteApi";
import type { ColorPalette } from "../theme/ThemeTypes";
import CustomButton from "../../components/custom/CustomButton";
import { Loader } from "../../components/custom/CustomLoader";
import { Check, User } from "lucide-react";
type ProfileForm = {
firstName: string;
lastName: string;
phoneNumber: string;
};
const formatDate: FormatDateFunction = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatName: FormatNameFunction = (firstName: string, lastName?: string | null) =>
[firstName, lastName].filter(Boolean).join(" ");
const renderStatusBadge: RenderStatusBadgeFunction = (status?: string) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ${status === "active"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "Unknown"}
</span>
);
const ProfilePage: React.FC = () => {
const { user, refreshUser } = useAuth();
const { t } = useTranslation(['profile', 'common']);
const { user, isLoading, refreshUser } = useAuth();
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [profileForm, setProfileForm] = useState<ProfileForm>({
firstName: "",
lastName: "",
phoneNumber: "",
});
const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
const [isProfileSubmitting, setIsProfileSubmitting] = useState(false);
// Theme Logic
const { currentPalette, setTheme } = useTheme();
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Form states
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
const [phone, setPhone] = useState(user?.phone_number || "");
const [isThemesLoading, setIsThemesLoading] = useState(false);
useEffect(() => {
const fetchPalettes = async () => {
setIsLoading(true);
setIsThemesLoading(true);
try {
const data = await paletteApi.getAllPalettes();
setPalettes(data);
} catch (error) {
console.error("Failed to fetch palettes", error);
} finally {
setIsLoading(false);
setIsThemesLoading(false);
}
};
fetchPalettes();
}, []);
// Update form when user data changes
useEffect(() => {
if (user) {
setFirstName(user.first_name || "");
setLastName(user.last_name || "");
setPhone(user.phone_number || "");
}
}, [user]);
const handleThemeSelect = (palette: ColorPalette) => {
setTheme(palette);
toast.success("Theme updated successfully");
};
const handleProfileUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setIsSaving(true);
try {
await authApi.updateProfile(user.id, {
first_name: firstName,
last_name: lastName,
phone_number: phone,
});
await refreshUser();
toast.success("Profile updated successfully");
} catch (error) {
console.error("Failed to update profile", error);
toast.error("Failed to update profile");
} finally {
setIsSaving(false);
}
};
if (isLoading && palettes.length === 0) {
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader />
<div className="flex items-center justify-center min-h-[400px]">
<CustomLoader />
</div>
);
}
if (!user) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-6 text-sm text-yellow-800">
{t('messages.loadError')}
</div>
);
}
const fullName = formatName(user.first_name, user.last_name);
const initials =
user.first_name && user.last_name
? `${user.first_name[0]}${user.last_name[0]}`
: user.first_name
? user.first_name[0]
: "U";
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setPasswordForm((prev) => ({ ...prev, [name]: value }));
setPasswordError("");
};
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError("");
setPasswordSuccess("");
// Client-side validation
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
setPasswordError(t('messages.requiredFields'));
return;
}
if (passwordForm.newPassword.length < 8) {
setPasswordError(t('messages.passwordLength'));
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError(t('messages.passwordMismatch'));
return;
}
setIsSubmitting(true);
try {
const response = await authApi.resetPassword(
passwordForm.currentPassword,
passwordForm.newPassword
);
setPasswordSuccess(response.message || t('messages.passwordSuccess'));
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
// Close modal after 1.5 seconds
setTimeout(() => {
setIsPasswordModalOpen(false);
setPasswordSuccess("");
}, 1500);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to update password";
setPasswordError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handlePasswordModalClose = () => {
setIsPasswordModalOpen(false);
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
setPasswordError("");
setPasswordSuccess("");
};
// Edit Profile handlers
const handleOpenEditModal = () => {
if (user) {
setProfileForm({
firstName: user.first_name || "",
lastName: user.last_name || "",
phoneNumber: user.phone_number || "",
});
}
setIsEditModalOpen(true);
};
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProfileForm((prev) => ({ ...prev, [name]: value }));
setProfileError("");
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setProfileError("");
setProfileSuccess("");
if (!user) return;
if (!profileForm.firstName.trim()) {
setProfileError(t('messages.firstNameRequired'));
return;
}
setIsProfileSubmitting(true);
try {
await authApi.updateProfile(user.id, {
first_name: profileForm.firstName.trim(),
last_name: profileForm.lastName.trim() || undefined,
phone_number: profileForm.phoneNumber.trim() || undefined,
});
setProfileSuccess(t('messages.profileSuccess'));
// Refresh user data
if (refreshUser) {
await refreshUser();
}
// Close modal after 1.5 seconds
setTimeout(() => {
setIsEditModalOpen(false);
setProfileSuccess("");
}, 1500);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to update profile";
setProfileError(errorMessage);
} finally {
setIsProfileSubmitting(false);
}
};
const handleEditModalClose = () => {
setIsEditModalOpen(false);
setProfileError("");
setProfileSuccess("");
};
return (
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-[var(--text-primary)]">Profile Settings</h1>
<p className="text-[var(--text-secondary)] mt-2">Manage your account settings and preferences.</p>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
<p className="text-sm text-[var(--text-secondary)]">
{t('subTitle')}
</p>
</div>
{/* Profile Information Section */}
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
<div className="flex items-center gap-4 mb-6">
<div className="p-3 bg-blue-100 rounded-lg text-blue-600">
<User size={24} />
</div>
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Personal Information</h2>
<p className="text-sm text-[var(--text-secondary)]">Update your personal details.</p>
</div>
</div>
<form onSubmit={handleProfileUpdate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">First Name</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Last Name</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Email</label>
<input
type="email"
value={user?.email || ""}
disabled
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background-secondary)] text-[var(--text-secondary)] cursor-not-allowed"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Phone Number</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
/>
</div>
</div>
<div className="flex justify-end pt-4">
<CustomButton type="submit" loading={isSaving} variant="primary">
Save Changes
</CustomButton>
</div>
</form>
</div>
{/* Theme Selection Section */}
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
<div className="mb-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Theme Preferences</h2>
<p className="text-sm text-[var(--text-secondary)]">Choose a color theme for your interface.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{palettes.map((palette) => (
<button
key={palette.id}
onClick={() => handleThemeSelect(palette)}
className={`
relative flex items-center gap-3 p-3 rounded-xl border text-left transition-all duration-200
${currentPalette?.id === palette.id
? "border-[var(--primary)] bg-[var(--primary)]/5 ring-1 ring-[var(--primary)]"
: "border-[var(--card-border)] hover:border-[var(--text-secondary)]"
}
`}
>
<div className="flex gap-1">
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.primary }} />
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.sidebar_bg }} />
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.header_bg }} />
{/* Profile Card */}
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
{/* User Header Section */}
<div className="border-b border-(--card-border) bg-(--table-row-hover) px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{/* User Info */}
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-indigo-600 text-2xl font-bold text-white shadow-lg">
{initials}
</div>
<div>
<h2 className="text-2xl font-bold text-(--text-primary)">{fullName}</h2>
<p className="text-sm text-(--text-secondary)">{user.email}</p>
<div className="mt-2">{renderStatusBadge(user.status)}</div>
</div>
</div>
<span className="font-medium text-[var(--text-primary)]">{palette.name}</span>
<div className="md:self-center flex flex-col gap-2">
<CustomButton
variant="outlined"
onClick={() => setIsPasswordModalOpen(true)}
>
<Key size={16} className="mr-2" />
{t('buttons.changePassword')}
</CustomButton>
<CustomButton
variant="outlined"
onClick={handleOpenEditModal}
>
<Pencil size={16} className="mr-2" />
{t('buttons.editProfile')}
</CustomButton>
</div>
</div>
</div>
{currentPalette?.id === palette.id && (
<div className="absolute top-3 right-3 text-[var(--primary)]">
<Check size={18} />
</div>
)}
</button>
))}
{/* User Details Section */}
<div className="p-6">
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
{t('sections.accountInfo')}
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* First Name */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.firstName')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.first_name}
</p>
</div>
{/* Last Name */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastName')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.last_name || "--"}
</p>
</div>
{/* Email */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.email')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.email}
</p>
</div>
{/* Phone Number */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.phone')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.phone_number || "--"}
</p>
</div>
{/* Tenant */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.tenant')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.tenant_name || "--"}
</p>
</div>
{/* Role */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.role')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{user.role?.role_name || "--"}
</p>
</div>
{/* Account Created */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.accountCreated')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{formatDate(user.created_at)}
</p>
</div>
{/* Last Updated */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastUpdated')}
</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{formatDate(user.updated_at)}
</p>
</div>
</div>
</div>
</div>
{/* Appearance / Theme Section */}
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
{t('sections.appearance')}
</h3>
<p className="text-sm text-[var(--text-secondary)] mb-6">
{t('sections.appearanceDesc')}
</p>
{isThemesLoading ? (
<div className="flex justify-center py-4">
<CustomLoader />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{palettes.map((palette) => (
<button
key={palette.id}
onClick={() => setTheme(palette)}
className={`
relative flex items-center p-4 border rounded-xl text-left transition-all hover:shadow-md
${currentPalette?.id === palette.id
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
: "border-[var(--card-border)] bg-[var(--card-bg)] hover:border-[var(--text-secondary)]"
}
`}
>
<div className="flex-1">
<h4 className={`font-medium ${currentPalette?.id === palette.id ? "text-blue-700" : "text-[var(--text-primary)]"}`}>
{palette.name}
</h4>
{palette.description && (
<p className={`text-xs mt-1 ${currentPalette?.id === palette.id ? "text-blue-600" : "text-[var(--text-secondary)]"}`}>
{palette.description}
</p>
)}
</div>
{/* Color Preview Circles */}
<div className="flex -space-x-2 overflow-hidden ml-4">
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.sidebar_bg }} />
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.primary }} />
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.background }} />
</div>
{currentPalette?.id === palette.id && (
<div className="absolute top-0 right-0 -mt-2 -mr-2 bg-blue-500 text-white rounded-full p-1 shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
))}
</div>
)}
</div>
{/* Change Password Modal */}
<CustomModal
isOpen={isPasswordModalOpen}
onClose={handlePasswordModalClose}
title={t('modals.changePasswordTitle')}
size="lg"
footer={
<>
<CustomButton
variant="outlined"
onClick={handlePasswordModalClose}
disabled={isSubmitting}
>
{t('common:actions.cancel')}
</CustomButton>
<CustomButton
type="submit"
form="change-password-form"
variant="primary"
loading={isSubmitting}
>
{t('buttons.updatePassword')}
</CustomButton>
</>
}
>
<form id="change-password-form" onSubmit={handlePasswordSubmit} className="space-y-4">
<CustomInput
label={t('modals.currentPassword')}
name="currentPassword"
type="password"
value={passwordForm.currentPassword}
onChange={handlePasswordChange}
required
placeholder={t('modals.currentPasswordPlaceholder')}
/>
<CustomInput
label={t('modals.newPassword')}
name="newPassword"
type="password"
value={passwordForm.newPassword}
onChange={handlePasswordChange}
required
placeholder={t('modals.newPasswordPlaceholder')}
/>
<CustomInput
label={t('modals.confirmPassword')}
name="confirmPassword"
type="password"
value={passwordForm.confirmPassword}
onChange={handlePasswordChange}
required
placeholder={t('modals.confirmPasswordPlaceholder')}
/>
{passwordError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{passwordError}
</div>
)}
{passwordSuccess && (
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
{passwordSuccess}
</div>
)}
</form>
</CustomModal>
{/* Edit Profile Modal */}
<CustomModal
isOpen={isEditModalOpen}
onClose={handleEditModalClose}
title={t('modals.editProfileTitle')}
size="lg"
footer={
<>
<CustomButton
variant="outlined"
onClick={handleEditModalClose}
disabled={isProfileSubmitting}
>
{t('common:actions.cancel')}
</CustomButton>
<CustomButton
type="submit"
form="edit-profile-form"
variant="primary"
loading={isProfileSubmitting}
>
{t('buttons.saveChanges')}
</CustomButton>
</>
}
>
<form id="edit-profile-form" onSubmit={handleProfileSubmit} className="space-y-4">
<CustomInput
label={t('fields.firstName')}
name="firstName"
value={profileForm.firstName}
onChange={handleProfileChange}
required
placeholder={t('modals.firstNamePlaceholder')}
/>
<CustomInput
label={t('fields.lastName')}
name="lastName"
value={profileForm.lastName}
onChange={handleProfileChange}
placeholder={t('modals.lastNamePlaceholder')}
/>
<CustomInput
label={t('fields.phone')}
name="phoneNumber"
type="tel"
value={profileForm.phoneNumber}
onChange={handleProfileChange}
placeholder={t('modals.phonePlaceholder')}
/>
{profileError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{profileError}
</div>
)}
{profileSuccess && (
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
{profileSuccess}
</div>
)}
</form>
</CustomModal>
</div>
);
};
+13
View File
@@ -0,0 +1,13 @@
import type { JSX } from "react/jsx-dev-runtime";
export interface PasswordForm {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export type FormatDateFunction = (value: string) => string;
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
export type RenderStatusBadgeFunction = (status?: string) => JSX.Element;
+52
View File
@@ -0,0 +1,52 @@
import { apiClient } from "../../lib/apiClient";
import type {
Role,
RoleCreateRequest,
RoleUpdateRequest,
RoleAccess,
RolePaginatedResponse,
} from "./RolesTypes";
type RoleWithAccesses = Role & { accesses: RoleAccess[] };
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}` : "";
};
export const rolesApi = {
getAll: () => apiClient.get<Role[]>("/api/role/get"),
getById: (roleId: string) =>
apiClient.get<RoleWithAccesses>(`/api/role/get/${roleId}`),
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
create: (payload: RoleCreateRequest) =>
apiClient.post<Role>("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }),
update: (roleId: string, payload: RoleUpdateRequest) =>
apiClient.put<Role>(`/api/role/update/${roleId}`, payload, { successMessage: "Role updated successfully", errorMessage: "Failed to update role" }),
remove: (roleId: string) =>
apiClient.delete<{ message: string }>(`/api/role/delete/${roleId}`, { successMessage: "Role deleted", errorMessage: "Failed to delete role" }),
getPaginated: (params: {
page?: number;
page_size?: number;
search?: string;
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
});
return apiClient.get<RolePaginatedResponse>(`/api/role/list${queryString}`);
},
};
+38
View File
@@ -0,0 +1,38 @@
export type Role = {
id: string;
role_name: string;
tenant_id?: string | null;
tenant_name?: string | null;
is_default: boolean;
created_at: string;
updated_at: string;
};
export type RoleAccess = {
id: string;
access_code: string;
category: string;
name: string;
parent_id?: string | null;
};
export type RoleCreateRequest = {
role_name: string;
tenant_id?: string;
access_ids?: string[];
is_default?: boolean;
};
export type RoleUpdateRequest = {
role_name?: string;
access_ids?: string[] | null;
is_default?: boolean;
};
export type RolePaginatedResponse = {
items: Role[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
@@ -0,0 +1,280 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
CustomButton,
CustomDropdown,
CustomInput,
CustomBackButton,
} from "../../../components/custom";
import { rolesApi } from "../RolesApi";
import type { RoleAccess, RoleCreateRequest } from "../RolesTypes";
import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { useAuth } from "../../../context/AuthContext";
import { GroupedAccessSelector } from "./GroupedAccessSelector";
const AddRoles = () => {
const { t } = useTranslation(['roles', 'common']);
const { user, hasAccess, isLoading: isAuthLoading } = useAuth();
const canReadAllTenants = hasAccess("superadmin.tenant.read");
const navigate = useNavigate();
const [formData, setFormData] = useState<RoleCreateRequest>({
role_name: "",
tenant_id: "",
access_ids: [],
is_default: false,
});
const [isLoading, setIsLoading] = useState(false);
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [isTenantLoading, setIsTenantLoading] = useState(false);
const [currentTenantName, setCurrentTenantName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (isAuthLoading) {
return;
}
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await rolesApi.getAccesses();
if (isMounted) {
setAccessOptions(data);
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load access options.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsAccessLoading(false);
}
}
};
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.";
setErrorMessage(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.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsTenantLoading(false);
}
}
};
loadAccesses();
loadTenants();
return () => {
isMounted = false;
};
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Filter accessOptions to only show what the current user has access to
const availableAccessOptions = useMemo(() => {
if (!user?.role?.accesses) return [];
// If the user has access codes in their token, filter the list
return accessOptions.filter((option) =>
user.role?.accesses.includes(option.access_code)
);
}, [accessOptions, user]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage("");
setIsLoading(true);
try {
const payload: RoleCreateRequest = {
role_name: formData.role_name.trim(),
tenant_id: formData.tenant_id?.trim() || undefined,
access_ids: formData.access_ids ?? [],
is_default: formData.is_default,
};
await rolesApi.create(payload);
navigate("/roles");
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to add role.";
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="/roles" tooltip={t('backToRoles')} />
<div>
<h1 className="text-2xl font-semibold text-gray-900">{t('add')}</h1>
<p className="text-sm text-gray-500">
{t('subTitle')}
</p>
</div>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label={t('fields.roleName')}
name="role_name"
placeholder={t('fields.roleNamePlaceholder')}
value={formData.role_name}
onChange={handleChange}
required
/>
{canReadAllTenants ? (
<CustomDropdown
label={t('fields.tenant')}
name="tenant_id"
placeholder={
isTenantLoading
? t('fields.tenantLoading')
: t('fields.tenantPlaceholder')
}
value={formData.tenant_id ?? ""}
onChange={(event) =>
setFormData((prev) => ({
...prev,
tenant_id: event.target.value || undefined,
}))
}
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.tenantAssigned')}
disabled
readOnly
/>
)}
</div>
{canReadAllTenants && (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_default"
name="is_default"
checked={formData.is_default || false}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_default: e.target.checked }))
}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="is_default" className="text-sm font-medium text-gray-700">
{t('fields.isDefault')}
</label>
</div>
)}
<GroupedAccessSelector
allAccesses={availableAccessOptions}
selectedIds={formData.access_ids || []}
onChange={(ids) =>
setFormData((prev) => ({ ...prev, access_ids: ids }))
}
isLoading={isAccessLoading}
/>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end pt-4">
<CustomButton type="submit" variant="primary" loading={isLoading}>
{t('actions.create')}
</CustomButton>
</div>
</form>
</div>
);
};
export default AddRoles;
@@ -0,0 +1,519 @@
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,
CustomConfirmationModal,
CustomInput,
CustomModal,
CustomTable,
CustomLoader,
CustomActionMenu,
CustomActionItem,
} from "../../../components/custom";
import { GroupedAccessSelector } from "./GroupedAccessSelector";
import { GroupedAccessViewer } from "./GroupedAccessViewer";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import type {
Role,
RoleAccess,
RoleUpdateRequest,
RolePaginatedResponse,
} from "../RolesTypes";
import { rolesApi } from "../RolesApi";
import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
import { useAuth } from "../../../context/AuthContext";
import { useDebounce } from "../../../components/hooks/useDebounce";
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;
}
};
const AllRoles = () => {
const { t, i18n } = useTranslation(['roles', 'common']);
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
const canReadAllTenants = hasAccess("superadmin.tenant.read");
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Filter access options based on current user's permissions
const availableAccessOptions = useMemo(() => {
if (
!currentUser ||
!currentUser.role ||
!currentUser.role.accesses ||
!Array.isArray(currentUser.role.accesses)
) {
return [];
}
return accessOptions.filter((opt) =>
currentUser.role!.accesses.includes(opt.access_code)
);
}, [accessOptions, currentUser]);
useEffect(() => {
let isMounted = true;
const loadSupportingData = async () => {
setIsAccessLoading(true);
try {
const accesses = await rolesApi.getAccesses();
if (isMounted) setAccessOptions(accesses);
if (canReadAllTenants) {
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 supporting data:", err);
} finally {
if (isMounted) setIsAccessLoading(false);
}
};
const loadRoles = async () => {
setIsLoading(true);
setErrorMessage("");
try {
const response: RolePaginatedResponse = await rolesApi.getPaginated({
page,
page_size: pageSize,
search: debouncedSearch || undefined,
});
if (isMounted) {
setRoles(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} catch (err) {
if (isMounted) {
const msg = err instanceof Error ? err.message : t('messages.error');
setErrorMessage(msg);
}
} finally {
if (isMounted) setIsLoading(false);
}
};
if (!isAuthLoading) {
loadSupportingData();
loadRoles();
}
return () => {
isMounted = false;
};
}, [
isAuthLoading,
canReadAllTenants,
currentUser?.tenant_id,
page,
pageSize,
debouncedSearch,
t
]);
// Reset page when search changes
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
// Restore search focus after loading
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;
if (currentUser?.tenant_id === tenantId && currentUser?.tenant_name) {
return currentUser.tenant_name;
}
return tenantId;
},
[tenants, currentTenant, currentUser]
);
const openView = useCallback(async (role: Role) => {
setSelectedRole(role);
setSelectedRoleAccesses(null);
setIsViewOpen(true);
setIsDetailLoading(true);
try {
const details = await rolesApi.getById(role.id);
setSelectedRoleAccesses(details.accesses || []);
} catch {
setSelectedRoleAccesses([]);
} finally {
setIsDetailLoading(false);
}
}, []);
const openEdit = useCallback(async (role: Role) => {
setSelectedRole(role);
setEditForm({ role_name: role.role_name, is_default: role.is_default });
setEditAccessIds([]);
setEditError("");
setIsEditOpen(true);
try {
const details = await rolesApi.getById(role.id);
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
}
}, [t]);
const openDelete = useCallback((role: Role) => {
setSelectedRole(role);
setDeleteError("");
setIsDeleteOpen(true);
}, []);
const closeView = useCallback(() => {
setIsViewOpen(false);
setSelectedRole(null);
setSelectedRoleAccesses(null);
}, []);
const closeEdit = useCallback(() => {
setIsEditOpen(false);
setSelectedRole(null);
setEditError("");
}, []);
const closeDelete = useCallback(() => {
setIsDeleteOpen(false);
setSelectedRole(null);
setDeleteError("");
}, []);
const handleEditChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setEditForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
},
[]
);
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRole) return;
setIsSaving(true);
setEditError("");
try {
const payload: RoleUpdateRequest = {
role_name: editForm.role_name.trim(),
access_ids: editAccessIds.length ? editAccessIds : null,
is_default: editForm.is_default,
};
const updated = await rolesApi.update(selectedRole.id, payload);
setRoles((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
closeEdit();
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedRole) return;
setIsDeleting(true);
try {
await rolesApi.remove(selectedRole.id);
setRoles((prev) => prev.filter((r) => r.id !== selectedRole.id));
closeDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : t('messages.error'));
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<Array<ColumnDef<Role>>>(
() => [
{ key: "role_name", header: t('columns.roleName') },
{
key: "tenant_id",
header: t('columns.tenant'),
render: (row) => getTenantName(row.tenant_id),
},
{
key: "created_at",
header: t('columns.created'),
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.created_at, i18n.language)}
</div>
),
},
{
key: "updated_at",
header: t('columns.updated'),
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.updated_at, i18n.language)}
</div>
),
},
{
key: "id",
header: t('columns.actions'),
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.role.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.role.update">
{(!row.is_default || canReadAllTenants) && (
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
)}
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.role.delete">
{(!row.is_default || canReadAllTenants) && (
<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, openView, openEdit, openDelete, t, i18n.language]
);
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.role.create">
<Link to="/roles/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={roles}
columns={columns}
getRowId={(row) => row.id}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title={t('details')}
size="lg"
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
>
{selectedRole ? (
<div className="space-y-4">
<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.roleName')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{selectedRole.role_name}</p>
</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(selectedRole.tenant_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(selectedRole.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(selectedRole.updated_at, i18n.language)}</p>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">Accesses</p>
{isDetailLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 text-sm text-[var(--text-secondary)]">
<CustomLoader />
</div>
) : (
<GroupedAccessViewer accesses={selectedRoleAccesses || []} />
)}
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">{t('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-role-form" variant="primary" loading={isSaving}>{t('update')}</CustomButton>
</>
}
>
<form id="edit-role-form" onSubmit={handleUpdate} className="space-y-6">
<CustomInput
label={t('columns.roleName')}
name="role_name"
value={editForm.role_name}
onChange={handleEditChange}
required
/>
{canReadAllTenants && (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="edit_is_default"
name="is_default"
checked={editForm.is_default || false}
onChange={handleEditChange}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="edit_is_default" className="text-sm font-medium text-[var(--text-primary)]">
Set as Default Role
</label>
</div>
)}
<GroupedAccessSelector
allAccesses={availableAccessOptions}
selectedIds={editAccessIds}
onChange={setEditAccessIds}
isLoading={isAccessLoading}
/>
{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.confirmDelete')}
description={deleteError || t('messages.confirmDelete')}
confirmText={t('actions.delete')}
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllRoles;
@@ -0,0 +1,296 @@
import { useMemo } from "react";
import type { RoleAccess } from "../RolesTypes";
import { CustomLoader } from "../../../components/custom";
interface GroupedAccessSelectorProps {
allAccesses: RoleAccess[];
selectedIds: string[];
onChange: (ids: string[]) => void;
isLoading?: boolean;
}
interface CategoryGroup {
category: string;
accesses: RoleAccess[];
children: Record<string, CategoryGroup>;
}
export const GroupedAccessSelector = ({
allAccesses,
selectedIds = [],
onChange,
isLoading = false,
}: GroupedAccessSelectorProps) => {
const hierarchicalGroups = useMemo(() => {
const parents: RoleAccess[] = [];
const children: Record<string, RoleAccess[]> = {};
allAccesses.forEach(access => {
if (!access.parent_id) {
parents.push(access);
} else {
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
}
});
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
}
categoryGroups[parent.category].accesses.push(parent);
const parentChildren = children[parent.id] || [];
parentChildren.forEach(child => {
if (!categoryGroups[parent.category].children[child.category]) {
categoryGroups[parent.category].children[child.category] = {
category: child.category,
accesses: [],
children: {}
};
}
categoryGroups[parent.category].children[child.category].accesses.push(child);
});
});
return categoryGroups;
}, [allAccesses]);
const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => {
const accesses = [...group.accesses];
Object.values(group.children).forEach(child => {
accesses.push(...child.accesses);
});
return accesses;
};
const isCategorySelected = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
return (
categoryAccesses.length > 0 &&
categoryAccesses.every((access) => selectedIds.includes(access.id))
);
};
const isCategoryIndeterminate = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
const selectedCount = categoryAccesses.filter((access) =>
selectedIds.includes(access.id)
).length;
return selectedCount > 0 && selectedCount < categoryAccesses.length;
};
const toggleCategory = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
const allSelected = isCategorySelected(group);
const categoryIds = categoryAccesses.map((a) => a.id);
let newIds: string[];
if (allSelected) {
newIds = selectedIds.filter((id) => !categoryIds.includes(id));
} else {
const uniqueIds = new Set([...selectedIds, ...categoryIds]);
newIds = Array.from(uniqueIds);
}
onChange(newIds);
};
// --- Subcategory Helpers ---
const isSubcategorySelected = (accesses: RoleAccess[]) => {
return (
accesses.length > 0 &&
accesses.every((access) => selectedIds.includes(access.id))
);
};
const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => {
const selectedCount = accesses.filter((access) =>
selectedIds.includes(access.id)
).length;
return selectedCount > 0 && selectedCount < accesses.length;
};
const toggleSubcategory = (accesses: RoleAccess[]) => {
const allSelected = isSubcategorySelected(accesses);
const accessIds = accesses.map((a) => a.id);
let newIds: string[];
if (allSelected) {
newIds = selectedIds.filter((id) => !accessIds.includes(id));
} else {
const uniqueIds = new Set([...selectedIds, ...accessIds]);
newIds = Array.from(uniqueIds);
}
onChange(newIds);
};
// --- Global Helpers ---
const isAllSelected = () => {
return (
allAccesses.length > 0 &&
allAccesses.every((access) => selectedIds.includes(access.id))
);
};
const isAllIndeterminate = () => {
const selectedCount = selectedIds.length;
return selectedCount > 0 && selectedCount < allAccesses.length;
};
const toggleAll = () => {
if (isAllSelected()) {
onChange([]);
} else {
const allIds = allAccesses.map((a) => a.id);
onChange(allIds);
}
};
// --- Individual Helper ---
const toggleAccess = (accessId: string) => {
const newIds = selectedIds.includes(accessId)
? selectedIds.filter((id) => id !== accessId)
: [...selectedIds, accessId];
onChange(newIds);
};
if (isLoading) {
return <CustomLoader />;
}
if (allAccesses.length === 0) {
return <p className="text-sm text-gray-500">No permissions available.</p>;
}
return (
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium text-gray-900">Permissions</h3>
{/* Global Select All */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="select-all"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isAllSelected()}
ref={(el) => {
if (el) el.indeterminate = isAllIndeterminate();
}}
onChange={toggleAll}
/>
<label
htmlFor="select-all"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
Select All Permissions
</label>
</div>
</div>
<div className="space-y-6">
{Object.entries(hierarchicalGroups).map(([category, group]) => (
<div
key={category}
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
>
{/* Category Header */}
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isCategorySelected(group)}
ref={(el) => {
if (el) el.indeterminate = isCategoryIndeterminate(group);
}}
onChange={() => toggleCategory(group)}
/>
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wide">
{category}
</span>
</div>
{/* Parent Accesses */}
{group.accesses.length > 0 && (
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{group.accesses.map((access) => (
<label
key={access.id}
className="flex items-start gap-3 cursor-pointer group"
>
<input
type="checkbox"
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
checked={selectedIds.includes(access.id)}
onChange={() => toggleAccess(access.id)}
/>
<span className="text-sm text-gray-600 group-hover:text-gray-900">
{access.name}
</span>
</label>
))}
</div>
)}
{/* Child Categories */}
{Object.keys(group.children).length > 0 && (
<div className="space-y-4 mt-4">
{Object.entries(group.children).map(([childCategory, childGroup]) => (
<div
key={childCategory}
className="rounded-md border border-gray-300 bg-white p-3 ml-4"
>
{/* Subcategory Header */}
<div className="mb-3 flex items-center gap-2 border-b border-gray-200 pb-2">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isSubcategorySelected(childGroup.accesses)}
ref={(el) => {
if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses);
}}
onChange={() => toggleSubcategory(childGroup.accesses)}
/>
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
{childCategory}
</span>
</div>
{/* Subcategory Accesses */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{childGroup.accesses.map((access) => (
<label
key={access.id}
className="flex items-start gap-2 cursor-pointer group"
>
<input
type="checkbox"
className="mt-0.5 size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
checked={selectedIds.includes(access.id)}
onChange={() => toggleAccess(access.id)}
/>
<span className="text-xs text-gray-600 group-hover:text-gray-900">
{access.name}
</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,105 @@
import { useMemo } from "react";
import type { RoleAccess } from "../RolesTypes";
interface GroupedAccessViewerProps {
accesses: RoleAccess[];
}
interface CategoryGroup {
category: string;
accesses: RoleAccess[];
children: Record<string, CategoryGroup>;
}
export const GroupedAccessViewer = ({ accesses }: GroupedAccessViewerProps) => {
const hierarchicalGroups = useMemo(() => {
const parents: RoleAccess[] = [];
const children: Record<string, RoleAccess[]> = {};
accesses.forEach(access => {
if (!access.parent_id) {
parents.push(access);
} else {
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
}
});
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
}
categoryGroups[parent.category].accesses.push(parent);
const parentChildren = children[parent.id] || [];
parentChildren.forEach(child => {
if (!categoryGroups[parent.category].children[child.category]) {
categoryGroups[parent.category].children[child.category] = {
category: child.category,
accesses: [],
children: {}
};
}
categoryGroups[parent.category].children[child.category].accesses.push(child);
});
});
return categoryGroups;
}, [accesses]);
if (!accesses || accesses.length === 0) {
return <p className="text-sm text-gray-500">No permissions assigned.</p>;
}
return (
<div className="space-y-4">
{Object.entries(hierarchicalGroups).map(([category, group]) => (
<div key={category} className="rounded-lg border border-gray-200 bg-gray-50/50 p-4">
<h4 className="mb-3 text-sm font-semibold text-gray-800 uppercase tracking-wide border-b border-gray-200 pb-2">
{category}
</h4>
{group.accesses.length > 0 && (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 mb-3">
{group.accesses.map((access) => (
<li key={access.id} className="flex items-start gap-2 text-sm text-gray-700">
<span className="mt-1.5 size-1.5 rounded-full bg-blue-500 shrink-0" />
<span>{access.name}</span>
</li>
))}
</ul>
)}
{Object.keys(group.children).length > 0 && (
<div className="space-y-3 mt-3">
{Object.entries(group.children).map(([childCategory, childGroup]) => (
<div key={childCategory} className="rounded-md border border-gray-300 bg-white p-3 ml-4">
<h5 className="mb-2 text-xs font-semibold text-gray-700 uppercase tracking-wide border-b border-gray-200 pb-1">
{childCategory}
</h5>
<ul className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{childGroup.accesses.map((access) => (
<li key={access.id} className="flex items-start gap-1.5 text-xs text-gray-600">
<span className="mt-1 size-1 rounded-full bg-green-500 shrink-0" />
<span>{access.name}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
))}
</div>
);
};
+14
View File
@@ -0,0 +1,14 @@
import { Route, Routes } from "react-router-dom";
import AllRoles from "./components/AllRoles";
import AddRoles from "./components/AddRoles";
const Roles: React.FC = () => {
return (
<Routes>
<Route index element={<AllRoles />} />
<Route path="add" element={<AddRoles />} />
</Routes>
);
};
export default Roles;
+57
View File
@@ -0,0 +1,57 @@
// src/features/Tenants/TenantsApi.ts
import { apiClient } from "../../lib/apiClient";
import type {
ApiMessage,
Tenant,
TenantCreateRequest,
TenantPaginatedResponse,
TenantUpdateRequest,
} from "./TenantsTypes";
// Helper to build URL with query params
const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `?${query}` : "";
};
export const tenantsApi = {
getAll: () => apiClient.get<Tenant[]>("/api/tenant/get"),
getMine: () => apiClient.get<Tenant>("/api/tenant/me"),
getById: (tenantId: string) =>
apiClient.get<Tenant>(`/api/tenant/get/${tenantId}`),
create: (payload: TenantCreateRequest) =>
apiClient.post<Tenant>("/api/tenant/create", payload, { successMessage: "Tenant created successfully", errorMessage: "Failed to create tenant" }),
update: (tenantId: string, payload: TenantUpdateRequest) =>
apiClient.put<Tenant>(`/api/tenant/update/${tenantId}`, payload, { successMessage: "Tenant updated successfully", errorMessage: "Failed to update tenant" }),
remove: (tenantId: string) =>
apiClient.delete<ApiMessage>(`/api/tenant/delete/${tenantId}`, { successMessage: "Tenant deleted", errorMessage: "Failed to delete tenant" }),
// Fixed: Manually append query params since apiClient doesn't support { params }
getPaginated: (params: {
page?: number;
page_size?: number;
search?: string;
is_active?: boolean | null;
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
is_active: params.is_active,
});
return apiClient.get<TenantPaginatedResponse>(
`/api/tenant/list${queryString}`
);
},
};
+35
View File
@@ -0,0 +1,35 @@
export type Tenant = {
id: string;
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type TenantCreateRequest = {
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string;
is_active?: boolean;
};
export type TenantUpdateRequest = {
tenant_name?: string;
tenant_domain?: string;
tenant_logo_url?: string | null;
is_active?: boolean;
};
export type TenantPaginatedResponse = {
items: Tenant[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
export type ApiMessage = {
message?: string;
};
@@ -0,0 +1,194 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CustomButton, CustomInput } from "../../../components/custom";
import CustomBackButton from "../../../components/custom/CustomBackButton";
import { CustomLoader } from "../../../components/custom";
import { tenantsApi } from "../TenantsApi";
import type { Tenant, TenantCreateRequest } from "../TenantsTypes";
import { useAuth } from "../../../context/AuthContext";
const AddTenants = () => {
const { hasAccess, isLoading: isAuthLoading } = useAuth();
const canCreateTenant = hasAccess("superadmin.tenant.create");
const navigate = useNavigate();
const [formData, setFormData] = useState<TenantCreateRequest>({
tenant_name: "",
tenant_domain: "",
tenant_logo_url: "",
});
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [isTenantLoading, setIsTenantLoading] = useState(true);
const [tenantError, setTenantError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (isAuthLoading || canCreateTenant) {
setIsTenantLoading(false);
return;
}
let isMounted = true;
const loadTenant = async () => {
setTenantError("");
setIsTenantLoading(true);
try {
const data = await tenantsApi.getMine();
if (isMounted) {
setCurrentTenant(data);
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load tenant.";
setTenantError(message);
}
} finally {
if (isMounted) {
setIsTenantLoading(false);
}
}
};
loadTenant();
return () => {
isMounted = false;
};
}, [canCreateTenant, isAuthLoading]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage("");
setIsLoading(true);
try {
const payload: TenantCreateRequest = {
tenant_name: formData.tenant_name.trim(),
tenant_domain: formData.tenant_domain.trim(),
tenant_logo_url: formData.tenant_logo_url?.trim() || undefined,
};
await tenantsApi.create(payload);
navigate("/tenants");
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to add tenant.";
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="/tenants" tooltip="Back to Tenants" />
<div>
<h1 className="text-2xl font-semibold text-gray-900">
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
</h1>
<p className="text-sm text-gray-500">
{canCreateTenant
? "Create a new tenant with domain and logo details."
: "Your account is assigned to this tenant."}
</p>
</div>
</div>
</div>
{!canCreateTenant ? (
isAuthLoading || isTenantLoading ? (
<div className="rounded-lg border border-gray-200 bg-white p-6 relative">
<CustomLoader />
</div>
) : tenantError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{tenantError}
</div>
) : currentTenant ? (
<div className="space-y-6 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Tenant Name"
value={currentTenant.tenant_name}
disabled
readOnly
/>
<CustomInput
label="Tenant Domain"
value={currentTenant.tenant_domain}
disabled
readOnly
/>
</div>
<CustomInput
label="Tenant Logo URL"
value={currentTenant.tenant_logo_url ?? ""}
disabled
readOnly
/>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-500">
No tenant assigned.
</div>
)
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Tenant Name"
name="tenant_name"
placeholder="Enter tenant name"
value={formData.tenant_name}
onChange={handleChange}
required
/>
<CustomInput
label="Tenant Domain"
name="tenant_domain"
placeholder="example.com"
value={formData.tenant_domain}
onChange={handleChange}
required
/>
</div>
<CustomInput
label="Tenant Logo URL"
name="tenant_logo_url"
placeholder="https://"
value={formData.tenant_logo_url}
onChange={handleChange}
/>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end">
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
Create Tenant
</CustomButton>
</div>
</form>
)}
</div>
);
};
export default AddTenants;
@@ -0,0 +1,599 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Edit2, Eye, Trash2 } from "lucide-react";
import {
CustomButton,
CustomCheckBox,
CustomConfirmationModal,
CustomInput,
CustomModal,
CustomTable,
CustomStatus,
CustomLoader,
CustomActionMenu,
CustomActionItem,
} from "../../../components/custom";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
import { tenantsApi } from "../TenantsApi";
import { useAuth } from "../../../context/AuthContext";
// Local implementation of useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Local implementation of ProtectedComponent
const ProtectedComponent: React.FC<{
requiredAccess: string;
children: React.ReactNode;
}> = ({ requiredAccess, children }) => {
const { hasAccess } = useAuth();
if (!hasAccess(requiredAccess)) {
return null;
}
return <>{children}</>;
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
// Check if the input likely contains specific time (ISO with T or explicit time chars)
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("en-GB", options);
} catch {
return dateString;
}
};
const AllTenants = () => {
const { hasAccess, isLoading: isAuthLoading } = useAuth();
const canReadAll = hasAccess("superadmin.tenant.read");
const [tenants, setTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [editForm, setEditForm] = useState({
tenant_name: "",
tenant_domain: "",
tenant_logo_url: "",
is_active: true,
});
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Pagination state
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
// Status filter state
const [statusFilter, setStatusFilter] = useState<boolean | null>(null);
// Ref for the search input to restore focus
const searchInputRef = useRef<HTMLInputElement>(null);
// Track previous loading state to detect transition from loading → idle
const prevLoadingRef = useRef(isLoading);
// Debounce search to avoid excessive API calls
const debouncedSearch = useDebounce(search, 500);
useEffect(() => {
let isMounted = true;
const loadTenants = async () => {
if (isAuthLoading) {
return;
}
setIsLoading(true);
setErrorMessage("");
try {
if (canReadAll) {
// Use paginated API for server-side pagination
const response = await tenantsApi.getPaginated({
page,
page_size: pageSize,
search: debouncedSearch || undefined,
is_active: statusFilter,
});
if (isMounted) {
setTenants(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} else {
// For non-superadmin, show only their tenant
const data = await tenantsApi.getMine();
if (isMounted) {
setTenants([data]);
setTotalRows(1);
setTotalPages(1);
}
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load tenants.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadTenants();
return () => {
isMounted = false;
};
}, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]);
// Restore focus to search input after loading completes (only if user was searching)
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim() !== "") {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
// Reset to page 1 when search or status filter changes
useEffect(() => {
setPage(1);
}, [debouncedSearch, statusFilter]);
const openView = useCallback((tenant: Tenant) => {
setSelectedTenant(tenant);
setIsViewOpen(true);
}, []);
const openEdit = useCallback((tenant: Tenant) => {
setSelectedTenant(tenant);
setEditForm({
tenant_name: tenant.tenant_name,
tenant_domain: tenant.tenant_domain,
tenant_logo_url: tenant.tenant_logo_url ?? "",
is_active: tenant.is_active,
});
setEditError("");
setIsEditOpen(true);
}, []);
const openDelete = useCallback((tenant: Tenant) => {
setSelectedTenant(tenant);
setDeleteError("");
setIsDeleteOpen(true);
}, []);
const closeView = useCallback(() => {
setIsViewOpen(false);
setSelectedTenant(null);
}, []);
const closeEdit = useCallback(() => {
setIsEditOpen(false);
setSelectedTenant(null);
setEditError("");
}, []);
const closeDelete = useCallback(() => {
setIsDeleteOpen(false);
setSelectedTenant(null);
setDeleteError("");
}, []);
const handleEditChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setEditForm((prev) => ({ ...prev, [name]: value }));
},
[]
);
const handleStatusChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setEditForm((prev) => ({ ...prev, is_active: event.target.checked }));
},
[]
);
const handleUpdate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedTenant) return;
setEditError("");
setIsSaving(true);
try {
const payload: TenantUpdateRequest = {
tenant_name: editForm.tenant_name.trim(),
tenant_domain: editForm.tenant_domain.trim(),
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
is_active: editForm.is_active,
};
const updatedTenant = await tenantsApi.update(selectedTenant.id, payload);
setTenants((prev) =>
prev.map((tenant) =>
tenant.id === updatedTenant.id ? updatedTenant : tenant
)
);
setIsEditOpen(false);
setSelectedTenant(null);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to update tenant.";
setEditError(message);
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedTenant) return;
setDeleteError("");
setIsDeleting(true);
try {
await tenantsApi.remove(selectedTenant.id);
setTenants((prev) =>
prev.filter((tenant) => tenant.id !== selectedTenant.id)
);
setIsDeleteOpen(false);
setSelectedTenant(null);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to delete tenant.";
setDeleteError(message);
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<Array<ColumnDef<Tenant>>>(
() => [
{ key: "tenant_name", header: "Tenant Name" },
{ key: "tenant_domain", header: "Domain" },
{
key: "tenant_logo_url",
header: "Logo",
searchable: false,
render: (row) =>
row.tenant_logo_url ? (
<img
src={row.tenant_logo_url}
alt={row.tenant_name}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<span className="text-(--text-secondary)"></span>
),
},
{
key: "is_active",
header: "Status",
searchable: false,
render: (row) => (
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
),
},
{
key: "created_at",
header: "Created",
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.created_at)}
</div>
),
},
{
key: "updated_at",
header: "Updated",
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.updated_at)}
</div>
),
},
{
key: "id",
header: "Action",
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="superadmin.tenant.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> View Details
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.tenant.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> Edit Tenant
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.tenant.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> Delete Tenant
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
},
],
[openDelete, openEdit, openView]
);
// Status filter control
const filterControls = canReadAll ? (
<select
value={statusFilter === null ? "all" : statusFilter ? "active" : "inactive"}
onChange={(e) => {
const value = e.target.value;
setStatusFilter(
value === "all" ? null : value === "active" ? true : false
);
}}
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)">All Statuses</option>
<option value="active" className="bg-(--card-bg) text-(--text-primary)">Active</option>
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">Inactive</option>
</select>
) : null;
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)]">Tenants</h1>
{/* <p className="text-sm text-[var(--text-secondary)]">
{totalRows} Tenant{totalRows === 1 ? "" : "s"} in total
</p> */}
</div>
<ProtectedComponent requiredAccess="superadmin.tenant.create">
<Link to="/tenants/add">
<CustomButton variant="primary">+ Add Tenant</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={tenants}
columns={columns}
getRowId={(row) => row.id}
manualPagination={canReadAll}
manualFiltering={canReadAll}
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
filterControls={filterControls}
/>
)}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title="Tenant Details"
size="lg"
footer={
<CustomButton type="button" variant="outlined" onClick={closeView}>
Close
</CustomButton>
}
>
{selectedTenant ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Name
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.tenant_name}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Domain
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.tenant_domain}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Logo
</p>
{selectedTenant.tenant_logo_url ? (
<img
src={selectedTenant.tenant_logo_url}
alt={selectedTenant.tenant_name}
className="h-12 w-12 rounded-full object-cover"
/>
) : (
<p className="text-sm text-[var(--text-secondary)]"></p>
)}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Status
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.is_active ? "Active" : "Inactive"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Created
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedTenant.created_at)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Updated
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedTenant.updated_at)}
</p>
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">No tenant selected.</p>
)}
</CustomModal>
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title="Edit Tenant"
size="lg"
footer={
<>
<CustomButton
type="button"
variant="outlined"
onClick={closeEdit}
disabled={isSaving}
>
Cancel
</CustomButton>
<CustomButton
type="submit"
form="edit-tenant-form"
variant="primary"
loading={isSaving}
>
Save Changes
</CustomButton>
</>
}
>
<form
id="edit-tenant-form"
onSubmit={handleUpdate}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Tenant Name"
name="tenant_name"
placeholder="Enter tenant name"
value={editForm.tenant_name}
onChange={handleEditChange}
required
/>
<CustomInput
label="Tenant Domain"
name="tenant_domain"
placeholder="example.com"
value={editForm.tenant_domain}
onChange={handleEditChange}
required
/>
</div>
<CustomInput
label="Tenant Logo URL"
name="tenant_logo_url"
placeholder="https://"
value={editForm.tenant_logo_url}
onChange={handleEditChange}
/>
<div className="flex mt-6 items-center">
{/* Checkbox component not found in standard custom, using explicit input or different component if needed,
but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox.
I'll assume I need to replace it with simple input or use CustomSwitch if I saw it.
I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it.
Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch".
Let's use CustomSwitch for "is_active" as it's cleaner. */}
<CustomCheckBox
label="Active"
name="is_active"
checked={editForm.is_active}
onChange={handleStatusChange}
/>
</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>
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
onConfirm={handleDelete}
title="Delete tenant?"
description={
deleteError || "This tenant will be permanently removed."
}
confirmText="Delete Tenant"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllTenants;
+13
View File
@@ -0,0 +1,13 @@
import { Route, Routes } from "react-router-dom";
import AllTenants from "./components/AllTenants";
import AddTenants from "./components/AddTenants";
const TenantsRoutes: React.FC = () => {
return (
<Routes>
<Route index element={<AllTenants />} />
<Route path="add" element={<AddTenants />} />
</Routes>
);
};
export default TenantsRoutes;
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { toast } from "react-toastify";
import {CustomButton} from "../../../components/custom/CustomButton";
import {CustomButton} from "../../../components/custom";
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
import { CustomActionMenu, CustomActionItem, CustomStatus } from "../../../components/custom";
import { Loader } from "../../../components/custom/CustomLoader";
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { Check } from "lucide-react";
import CustomModal from "../../../components/custom/CustomModal";
import CustomInput from "../../../components/custom/CustomInput";
import { CustomButton } from "../../../components/custom/CustomButton";
import {CustomButton} from "../../../components/custom";
import type { ColorPalette } from "../ThemeTypes";
interface PaletteFormProps {
@@ -0,0 +1,25 @@
import React from "react";
import type { ReactNode } from "react";
import { useAuth } from "../../context/AuthContext";
interface ProtectedComponentProps {
children: ReactNode;
requiredAccess?: string;
fallback?: ReactNode;
}
export const ProtectedComponent: React.FC<ProtectedComponentProps> = ({
children,
requiredAccess,
fallback = null,
}) => {
const { hasAccess, isLoading } = useAuth();
if (isLoading) return null;
if (requiredAccess && !hasAccess(requiredAccess)) {
return <>{fallback}</>;
}
return <>{children}</>;
};
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
import { CustomLoader } from "../custom";
interface ProtectedRouteProps {
children: React.ReactNode;
requiredAccess?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredAccess,
}) => {
const { isAuthenticated, isLoading, hasAccess } = useAuth();
if (isLoading) {
return <CustomLoader />;
}
if (!isAuthenticated) {
return <Navigate to="/signin" replace />;
}
if (requiredAccess && !hasAccess(requiredAccess)) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
+90
View File
@@ -0,0 +1,90 @@
import { useState, useCallback } from 'react';
interface StateData {
name: string;
state_code?: string;
}
export const useCountriesNow = () => {
const [statesLoading, setStatesLoading] = useState(false);
const [citiesLoading, setCitiesLoading] = useState(false);
const [states, setStates] = useState<string[]>([]);
const [cities, setCities] = useState<string[]>([]);
const fetchStates = useCallback(async (countryName: string): Promise<string[]> => {
if (!countryName) {
setStates([]);
return [];
}
setStatesLoading(true);
try {
const response = await fetch('https://countriesnow.space/api/v0.1/countries/states', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: countryName }),
});
if (response.ok) {
const data = await response.json();
if (data.data?.states) {
const stateNames = data.data.states.map((s: StateData) => s.name);
setStates(stateNames);
return stateNames;
}
}
setStates([]);
return [];
} catch (error) {
setStates([]);
return [];
} finally {
setStatesLoading(false);
}
}, []);
const fetchCities = useCallback(async (countryName: string, stateName: string): Promise<string[]> => {
if (!countryName || !stateName) {
setCities([]);
return [];
}
setCitiesLoading(true);
try {
const response = await fetch('https://countriesnow.space/api/v0.1/countries/state/cities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: countryName, state: stateName }),
});
if (response.ok) {
const data = await response.json();
if (data.data) {
setCities(data.data);
return data.data;
}
}
setCities([]);
return [];
} catch (error) {
setCities([]);
return [];
} finally {
setCitiesLoading(false);
}
}, []);
const clearStates = useCallback(() => setStates([]), []);
const clearCities = useCallback(() => setCities([]), []);
return {
fetchStates,
fetchCities,
clearStates,
clearCities,
states,
cities,
statesLoading,
citiesLoading,
};
};
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
@@ -0,0 +1,84 @@
import { useState } from 'react';
export interface PostalCodeResult {
city: string;
state: string;
success: boolean;
}
export const usePostalCodeLookup = () => {
const [loading, setLoading] = useState(false);
const lookupPostalCode = async (
postalCode: string,
countryCode: string
): Promise<PostalCodeResult> => {
if (!postalCode || postalCode.length < 3) {
return { city: '', state: '', success: false };
}
setLoading(true);
try {
if (countryCode.toUpperCase() === 'IN' && postalCode.length === 6) {
const response = await fetch(
`https://api.postalpincode.in/pincode/${postalCode}`
);
const data = await response.json();
if (data[0]?.Status === 'Success' && data[0]?.PostOffice?.length > 0) {
const { District, State } = data[0].PostOffice[0];
return {
city: District,
state: State,
success: true,
};
}
}
const zipResponse = await fetch(
`https://api.zippopotam.us/${countryCode.toLowerCase()}/${postalCode}`
);
if (zipResponse.ok) {
const data = await zipResponse.json();
if (data && data.places && data.places.length > 0) {
let selectedPlace = data.places[0];
if (data.places.length > 1) {
const cityPlace = data.places.find((place: any) => {
const placeName = place['place name'] || '';
return placeName.split(' ').length <= 2;
});
if (cityPlace) {
selectedPlace = cityPlace;
}
}
const city = selectedPlace['place name'] || '';
const state =
selectedPlace['state'] || selectedPlace['state abbreviation'] || '';
return {
city,
state,
success: true,
};
}
}
return { city: '', state: '', success: false };
} catch (error) {
return { city: '', state: '', success: false };
} finally {
setLoading(false);
}
};
return {
lookupPostalCode,
loading,
};
};
+13 -13
View File
@@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useSidebar } from "../../context/SidebarContext";
import { useAuth } from "../../context/AuthContext";
import { authApi } from "../../application/Authentication/AuthApi";
import { authApi } from "../../application/authentication/AuthApi";
import { clearAuthCookies } from "../../lib/authCookies";
import { useTranslation } from "react-i18next";
@@ -76,43 +76,43 @@ const AppHeader: React.FC = () => {
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" strokeOpacity="0.1" strokeWidth="0.5" className="text-[var(--header-text)]" />
</pattern>
<rect width="320" height="40" fill="url(#grid)" />
{/* Multiple route paths */}
<path d="M0 30 C40 30, 60 10, 100 10 S160 30, 200 20 S260 10, 320 15" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" fill="none" className="text-[var(--header-text)]" />
<path d="M0 20 C50 15, 80 25, 120 20 S180 10, 220 15 S280 25, 320 20" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1.5" fill="none" className="text-[var(--header-text)]" />
<path d="M0 10 C30 15, 70 5, 110 12 S170 20, 210 12 S270 8, 320 12" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1" strokeDasharray="4 2" fill="none" className="text-[var(--header-text)]" />
{/* Hub nodes with glow effect */}
<circle cx="40" cy="25" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
<circle cx="40" cy="25" r="3" fill="currentColor" className="text-[var(--primary)]" />
<circle cx="120" cy="15" r="8" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.5" strokeWidth="2" className="text-[var(--header-text)]" />
<circle cx="120" cy="15" r="4" fill="currentColor" className="text-[var(--primary)]" />
<circle cx="200" cy="18" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
<circle cx="200" cy="18" r="3" fill="currentColor" className="text-[var(--primary)]" />
<circle cx="280" cy="16" r="5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1" className="text-[var(--header-text)]" />
<circle cx="280" cy="16" r="2.5" fill="currentColor" className="text-[var(--primary)]" />
{/* Package icons */}
<g transform="translate(70, 22)">
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-[var(--header-text)]" />
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
</g>
<g transform="translate(160, 8)">
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
</g>
<g transform="translate(240, 20)">
<rect width="8" height="6" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
<line x1="0" y1="2" x2="8" y2="2" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
</g>
{/* Connecting dots */}
<circle cx="20" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
<circle cx="90" cy="12" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
+2 -4
View File
@@ -19,7 +19,7 @@ import {
import { useSidebar } from "../../context/SidebarContext";
import { usePermission } from "../../lib/usePermission";
import { useAuth } from "../../context/AuthContext";
import { authApi } from "../../application/Authentication/AuthApi";
import { authApi } from "../../application/authentication/AuthApi";
import { clearAuthCookies } from "../../lib/authCookies";
@@ -66,11 +66,9 @@ const navItems: NavItem[] = [
{
icon: <Palette size={22} />,
name: "Themes",
path: "/themes",
path: "/theme",
access: "superadmin.palette.read",
},
{
icon: <Settings size={22} />,
name: "Settings",
+10 -10
View File
@@ -1,10 +1,10 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import type { ReactNode } from "react";
import { authApi } from "../application/Authentication/AuthApi";
import { authApi } from "../application/authentication/AuthApi";
import type {
AuthUser,
SigninRequest,
} from "../application/Authentication/AuthTypes";
} from "../application/authentication/AuthTypes";
import {
setAuthCookies,
getAccessToken,
@@ -43,7 +43,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
try {
const userData = await authApi.me();
setUser(userData);
if (userData.preferred_language) {
i18n.changeLanguage(userData.preferred_language);
}
@@ -69,11 +69,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
}
};
const logout = async () => {
clearAuthCookies();
setUser(null);
window.location.href = "/signin";
};
const logout = async () => {
clearAuthCookies();
setUser(null);
window.location.href = "/signin";
};
const hasAccess = (accessCode: string): boolean => {
@@ -85,7 +85,7 @@ const logout = async () => {
try {
const userData = await authApi.me();
setUser(userData);
if (userData.preferred_language) {
i18n.changeLanguage(userData.preferred_language);
}
@@ -95,7 +95,7 @@ const logout = async () => {
const updateLanguage = async (language: string) => {
if (!user) throw new Error('No user logged in');
try {
const updatedUser = await authApi.updateLanguage(user.id, language);
setUser(updatedUser);
+1 -1
View File
@@ -1,4 +1,4 @@
import {type TokenResponse } from "../application/Authentication/AuthTypes";
import { type TokenResponse } from "../application/authentication/AuthTypes";
export const AUTH_COOKIE_KEYS = {
access: "access_token",
+9 -4
View File
@@ -1,12 +1,14 @@
import { Route, Routes, Navigate } from "react-router-dom";
import SignInPage from "../application/Authentication/SignInPage";
import SignUpPage from "../application/Authentication/SignUpPage";
import ResetPasswordPage from "../application/Authentication/ResetPasswordPage";
import SignInPage from "../application/authentication/SignInPage";
import SignUpPage from "../application/authentication/SignUpPage";
import ResetPasswordPage from "../application/authentication/ResetPasswordPage";
import Roles from "../application/roles";
import Dashboard from "../application/dashboard/Dashboard";
import ProfilePage from "../application/profile/ProfilePage";
import Tenants from "../application/tenants";
import ProtectedRoutes from "./ProtectedRoutes";
import Theme from "../application/theme";
const AppRoutes = () => {
return (
<Routes>
@@ -19,6 +21,9 @@ const AppRoutes = () => {
<Route element={<ProtectedRoutes />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/tenants/*" element={<Tenants />} />
<Route path="/roles/*" element={<Roles />} />
<Route path="/theme/*" element={<Theme />} />
{/* Redirect root to dashboard if logged in (ProtectedRoutes handles auth check usually, or we redirect to signin if not) */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>