feat: implement granular permission checks for tenant actions and add tenant metadata display to settings.

This commit is contained in:
Yashwin 2026-06-04 13:19:39 +05:30
parent 28f3d886ba
commit 43abf370c7
13 changed files with 217 additions and 126 deletions

View File

@ -78,19 +78,30 @@ export const AuthenticatedImage = ({
const isAuthRequired = !!(extractedFileId || isBackendUrl);
useEffect(() => {
const currentCachedUrl = cacheKey ? BLOB_CACHE.get(cacheKey) || null : null;
// If it's already a blob URL (local preview) or a data URL, use it directly
if (src && (src.startsWith("blob:") || src.startsWith("data:"))) {
setBlobUrl(src);
return;
}
if (!cacheKey) return;
// If we already have the blobUrl from initial state (cached), we don't need to fetch
if (blobUrl) return;
if (!cacheKey) {
setBlobUrl(null);
return;
}
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
if (isAuthRequired) {
// If we already have the blobUrl for this cacheKey, use it and don't fetch
if (currentCachedUrl) {
setBlobUrl(currentCachedUrl);
return;
}
// Otherwise, we need to fetch. Reset blobUrl to null first to show loading/fallback
setBlobUrl(null);
let isMounted = true;
const fetchImage = async () => {
// 3. Check if there's already a pending request for this same image
@ -154,7 +165,7 @@ export const AuthenticatedImage = ({
// For other external URLs, use them directly
setBlobUrl(src);
}
}, [fileId, src, cacheKey, isAuthRequired, blobUrl]);
}, [fileId, src, cacheKey, isAuthRequired, tenantId]);
if (isLoading) {
return (

View File

@ -25,6 +25,7 @@ import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { formatDate } from "@/utils/format-date";
import CodeBadge from "./CodeBadge";
import { usePermissions } from "@/hooks/usePermissions";
export interface WorkflowDefinitionsTableRef {
openNewModal: () => void;
@ -49,6 +50,7 @@ const WorkflowDefinitionsTable = forwardRef<
const reduxTenantId = useSelector(
(state: RootState) => state.auth.tenantId,
);
const { canCreate, canRead, hasPermission } = usePermissions();
const effectiveTenantId = tenantId || reduxTenantId || undefined;
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
@ -286,50 +288,59 @@ const WorkflowDefinitionsTable = forwardRef<
<div className="flex justify-end">
<ActionDropdown
actions={[
{
icon: <Copy className="w-4 h-4" />,
label: "Clone",
onClick: () => handleClone(wf.id, wf.name),
},
{
icon: <Eye className="w-4 h-4" />,
label: "View",
onClick: () => {
setViewDefinitionId(wf.id);
setIsViewModalOpen(true);
},
},
{
icon: <Edit className="w-4 h-4" />,
label: "Edit",
onClick: () => {
setSelectedDefinition(wf);
setIsModalOpen(true);
},
},
wf.status === "draft" || wf.status === "deprecated"
canCreate("workflow")
? {
icon: <Copy className="w-4 h-4" />,
label: "Clone",
onClick: () => handleClone(wf.id, wf.name),
}
: null,
canRead("workflow")
? {
icon: <Eye className="w-4 h-4" />,
label: "View",
onClick: () => {
setViewDefinitionId(wf.id);
setIsViewModalOpen(true);
},
}
: null,
canCreate("workflow")
? {
icon: <Edit className="w-4 h-4" />,
label: "Edit",
onClick: () => {
setSelectedDefinition(wf);
setIsModalOpen(true);
},
}
: null,
(wf.status === "draft" || wf.status === "deprecated") &&
canCreate("workflow")
? {
icon: <Play className="w-4 h-4" />,
label: "Activate",
onClick: () => handleActivate(wf.id),
}
: null,
wf.status === "active"
wf.status === "active" && canCreate("workflow")
? {
icon: <Power className="w-4 h-4" />,
label: "Deprecate",
onClick: () => handleDeprecate(wf.id),
}
: null,
{
icon: <Trash2 className="w-4 h-4" />,
label: "Delete",
variant: "danger",
onClick: () => {
setSelectedDefinition(wf);
setIsDeleteModalOpen(true);
},
},
hasPermission("workflow", "admin")
? {
icon: <Trash2 className="w-4 h-4" />,
label: "Delete",
variant: "danger",
onClick: () => {
setSelectedDefinition(wf);
setIsDeleteModalOpen(true);
},
}
: null,
].filter((a): a is any => a !== null)}
/>
</div>

View File

@ -56,7 +56,7 @@ interface RolesTableProps {
export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
({ tenantId, showHeader = true, compact = false }, ref): ReactElement => {
// const { primaryColor } = useAppTheme();
const { canCreate, canUpdate, canDelete } = usePermissions();
const { isTenantAdmin } = usePermissions();
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -239,11 +239,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
{
key: "name",
label: "Name",
render: (role) => (
<span className="">
{role.name}
</span>
),
render: (role) => <span className="">{role.name}</span>,
},
{
key: "code",
@ -287,19 +283,13 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
{
key: "user_count",
label: "Users",
render: (role) => (
<span className="">
{role.user_count || 0}
</span>
),
render: (role) => <span className="">{role.user_count || 0}</span>,
},
{
key: "created_at",
label: "Created Date",
render: (role) => (
<span className="">
{formatDate(role.created_at)}
</span>
<span className="">{formatDate(role.created_at)}</span>
),
},
{
@ -311,12 +301,12 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={
canUpdate("roles")
isTenantAdmin
? () => handleEditRole(role.id, role.name)
: undefined
}
onDelete={
canDelete("roles")
isTenantAdmin
? () => handleDeleteRole(role.id, role.name)
: undefined
}
@ -341,12 +331,12 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={
canUpdate("roles")
isTenantAdmin
? () => handleEditRole(role.id, role.name)
: undefined
}
onDelete={
canDelete("roles")
isTenantAdmin
? () => handleDeleteRole(role.id, role.name)
: undefined
}
@ -398,7 +388,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
onChange={setSearch}
placeholder="Search..."
/>
{canCreate("roles") && (
{isTenantAdmin && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
@ -535,9 +525,8 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
{/* Actions */}
<div className="flex items-center gap-2">
{/* New Role Button */}
{canCreate("roles") && (
{isTenantAdmin && (
<PrimaryButton
size="default"
className="flex items-center gap-2"

View File

@ -67,6 +67,7 @@ interface UsersTableProps {
export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(
({ tenantId, showHeader = true, compact = false }, ref): ReactElement => {
const { canUpdate } = usePermissions();
const { primaryColor } = useAppTheme();
const { canCreate } = usePermissions();
const [users, setUsers] = useState<User[]>([]);
@ -456,11 +457,14 @@ export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={() =>
handleEditUser(
user.id,
// , `${user.first_name} ${user.last_name}`
)
onEdit={
canUpdate("users")
? () =>
handleEditUser(
user.id,
// , `${user.first_name} ${user.last_name}`
)
: undefined
}
// onDelete={() =>
// handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)

View File

@ -80,6 +80,11 @@ export const usePermissions = () => {
[hasPermission]
);
const isTenantAdmin = useMemo(() => {
const rolesArray = Array.isArray(roles) ? roles : [];
return rolesArray.includes('tenant_admin') || rolesArray.includes('super_admin');
}, [roles]);
return {
hasPermission,
canCreate,
@ -87,5 +92,6 @@ export const usePermissions = () => {
canUpdate,
canDelete,
isSuperAdmin,
isTenantAdmin,
};
};

View File

@ -82,14 +82,30 @@ const contactDetailsSchema = z.object({
message: "Phone number must be exactly 10 digits",
},
),
address_line1: z.string().min(1, "Address is required"),
address_line2: z.string().optional().nullable(),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
address_line1: z
.string()
.min(1, "Address is required")
.max(255, "Maximum 255 characters allowed"),
address_line2: z
.string()
.max(255, "Maximum 255 characters allowed")
.optional()
.nullable(),
city: z
.string()
.min(1, "City is required")
.max(255, "Maximum 255 characters allowed"),
state: z
.string()
.min(1, "State is required")
.max(255, "Maximum 255 characters allowed"),
postal_code: z
.string()
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
country: z.string().min(1, "Country is required"),
country: z
.string()
.min(1, "Country is required")
.max(255, "Maximum 255 characters allowed"),
});
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
@ -1308,12 +1324,8 @@ const CreateTenantWizard = (): ReactElement => {
fileId={faviconFileAttachmentUuid}
src={faviconPreviewUrl || faviconFileUrl}
alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{
display: "block",
width: "64px",
height: "64px",
}}
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: "block", maxHeight: "80px" }}
/>
<button
type="button"

View File

@ -89,14 +89,30 @@ const contactDetailsSchema = z.object({
message: "Phone number must be exactly 10 digits",
},
),
address_line1: z.string().min(1, "Address is required"),
address_line2: z.string().optional().nullable(),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
address_line1: z
.string()
.min(1, "Address is required")
.max(255, "Maximum 255 characters allowed"),
address_line2: z
.string()
.max(255, "Maximum 255 characters allowed")
.optional()
.nullable(),
city: z
.string()
.min(1, "City is required")
.max(255, "Maximum 255 characters allowed"),
state: z
.string()
.min(1, "State is required")
.max(255, "Maximum 255 characters allowed"),
postal_code: z
.string()
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
country: z.string().min(1, "Country is required"),
country: z
.string()
.min(1, "Country is required")
.max(255, "Maximum 255 characters allowed"),
});
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
@ -1465,12 +1481,8 @@ const EditTenant = (): ReactElement => {
src={faviconPreviewUrl || faviconFileUrl}
tenantId={id}
alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{
display: "block",
width: "64px",
height: "64px",
}}
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: "block", maxHeight: "80px" }}
/>
<button
type="button"

View File

@ -240,7 +240,7 @@ const CreateDocument = (): ReactElement => {
const loadFiles = async (): Promise<void> => {
setIsLoadingFiles(true);
try {
const res = await documentService.listFiles();
const res = await documentService.listForDropdown();
setFiles(res.data || []);
} catch (err: any) {
showToast.error(

View File

@ -22,6 +22,7 @@ import { showToast } from "@/utils/toast";
import { Plus, Eye, Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import CodeBadge from "@/components/shared/CodeBadge";
import { usePermissions } from "@/hooks/usePermissions";
const categorySchema = z.object({
name: z.string().min(1, "Category name is required"),
@ -54,6 +55,7 @@ const DocumentCategories = (): ReactElement => {
useState<DocumentCategory | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { canCreate } = usePermissions();
const {
register,
@ -214,17 +216,21 @@ const DocumentCategories = (): ReactElement => {
onClick: () => handleView(category),
icon: <Eye className="w-4 h-4" />,
},
{
label: "Edit Category",
onClick: () => handleEdit(category),
icon: <Edit className="w-4 h-4" />,
},
{
label: "Delete",
onClick: () => handleDeleteClick(category),
icon: <Trash2 className="w-4 h-4" />,
variant: "danger",
},
...(canCreate("document")
? [
{
label: "Edit Category",
onClick: () => handleEdit(category),
icon: <Edit className="w-4 h-4" />,
},
{
label: "Delete",
onClick: () => handleDeleteClick(category),
icon: <Trash2 className="w-4 h-4" />,
variant: "danger" as const,
},
]
: []),
]}
/>
),
@ -274,7 +280,7 @@ const DocumentCategories = (): ReactElement => {
title: "Document Categories",
description:
"View and manage the document categories and their retention policies.",
action: (
action: canCreate("document") && (
<PrimaryButton
onClick={() => {
setEditingCategory(null);

View File

@ -14,6 +14,7 @@ import type { DocumentCategory, DocumentSummary } from "@/types/document";
import type { Module } from "@/types/module";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Plus, Search } from "lucide-react";
import { usePermissions } from "@/hooks/usePermissions";
const formatDate = (value?: string | null): string => {
if (!value) return "-";
@ -50,6 +51,7 @@ const Documents = (): ReactElement => {
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { canCreate } = usePermissions();
const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit));
@ -112,8 +114,8 @@ const Documents = (): ReactElement => {
offset,
]);
const columns: Column<DocumentSummary>[] = useMemo(
() => [
const columns: Column<DocumentSummary>[] = useMemo(() => {
const cols: Column<DocumentSummary>[] = [
{
key: "document_number",
label: "Document No",
@ -136,16 +138,12 @@ const Documents = (): ReactElement => {
{
key: "document_type",
label: "Type",
render: (doc) => (
<span className="">{doc.document_type || "-"}</span>
),
render: (doc) => <span className="">{doc.document_type || "-"}</span>,
},
{
key: "category",
label: "Category",
render: (doc) => (
<span className="">{doc.category || "-"}</span>
),
render: (doc) => <span className="">{doc.category || "-"}</span>,
},
{
key: "status",
@ -166,9 +164,7 @@ const Documents = (): ReactElement => {
key: "module_name",
label: "Module",
render: (doc) => (
<span className="">
{doc.module_name || "Platform"}
</span>
<span className="">{doc.module_name || "Platform"}</span>
),
},
{
@ -185,7 +181,10 @@ const Documents = (): ReactElement => {
<span className="">{formatDate(doc.updated_at)}</span>
),
},
{
];
if (canCreate("document")) {
cols.push({
key: "actions",
label: "Actions",
render: (doc) => (
@ -201,10 +200,11 @@ const Documents = (): ReactElement => {
Edit
</button>
),
},
],
[navigate],
);
});
}
return cols;
}, [navigate, canCreate, primaryColor]);
return (
<Layout
@ -222,10 +222,14 @@ const Documents = (): ReactElement => {
>
Manage Categories
</button> */}
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
<Plus className="w-3.5 h-3.5 mr-1" />
New Document
</PrimaryButton>
{canCreate("document") && (
<PrimaryButton
onClick={() => navigate("/tenant/documents/create")}
>
<Plus className="w-3.5 h-3.5 mr-1" />
New Document
</PrimaryButton>
)}
</div>
),
}}

View File

@ -447,6 +447,38 @@ const Settings = ({ customTenantId, hideLayout = false }: SettingsProps = {}): R
</div>
)}
{/* Tenant Details Section */}
{tenant && (
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-[#0f1724]">Tenant Details</h3>
<p className="text-sm font-normal text-[#9ca3af]">
General workspace details for this tenant.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-[#0f1724]">Tenant Name</label>
<input
type="text"
value={tenant.name}
readOnly
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#6b7280] w-full focus:outline-none cursor-not-allowed"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-[#0f1724]">Slug</label>
<input
type="text"
value={tenant.slug}
readOnly
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#6b7280] w-full focus:outline-none cursor-not-allowed"
/>
</div>
</div>
</div>
)}
{/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
{/* Section Header */}
@ -578,12 +610,8 @@ const Settings = ({ customTenantId, hideLayout = false }: SettingsProps = {}): R
fileId={faviconFileAttachmentUuid}
src={faviconPreviewUrl || faviconFileUrl}
alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{
display: "block",
width: "64px",
height: "64px",
}}
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: "block", maxHeight: "80px" }}
/>
<button
type="button"

View File

@ -6,8 +6,10 @@ import {
PrimaryButton,
} from "@/components/shared";
import { Plus } from "lucide-react";
import { usePermissions } from "@/hooks/usePermissions";
const WorkflowDefinationPage = (): ReactElement => {
const { canCreate } = usePermissions();
const tableRef = useRef<WorkflowDefinitionsTableRef>(null);
return (
@ -15,8 +17,9 @@ const WorkflowDefinationPage = (): ReactElement => {
currentPage="Workflow Definitions"
pageHeader={{
title: "Workflow Definitions",
description: "Create and manage document approval workflow definitions.",
action: (
description:
"Create and manage document approval workflow definitions.",
action: canCreate("workflow") && (
<PrimaryButton
size="default"
className="flex items-center gap-2"

View File

@ -100,6 +100,11 @@ export const documentService = {
return response.data;
},
listForDropdown: async (): Promise<{ success: boolean; data: FileAttachmentItem[] }> => {
const response = await apiClient.get<{ success: boolean; data: FileAttachmentItem[] }>("/files/dropdown-options");
return response.data;
},
getFileContent: async (fileId: string): Promise<{
success: boolean;
data: {