6 Commits
12 changed files with 344 additions and 216 deletions
+10 -22
View File
@@ -136,7 +136,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -456,7 +455,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -505,7 +503,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -2117,7 +2114,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -2174,7 +2172,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2185,7 +2182,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2235,7 +2231,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -2598,7 +2593,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2759,7 +2753,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3117,7 +3110,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -3294,7 +3288,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3848,7 +3841,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -4124,8 +4116,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/leaflet-control-geocoder": {
"version": "3.3.1",
@@ -4484,6 +4475,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4744,7 +4736,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4805,6 +4796,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -4820,6 +4812,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4832,7 +4825,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
@@ -4883,7 +4877,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4893,7 +4886,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5454,7 +5446,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5558,7 +5549,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5657,7 +5647,6 @@
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
@@ -5981,7 +5970,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -5,6 +5,7 @@ import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/c
import { ApiError } from "../../../lib/apiClient";
import type { SigninRequest } from "../AuthTypes";
import { useAuth } from "../../../context/AuthContext";
import { moduleApi } from "../../modules/user/UserModuleApi";
export default function SignInForm() {
const navigate = useNavigate();
@@ -33,6 +34,40 @@ export default function SignInForm() {
try {
await login(payload, isChecked);
// Check if this login originated from a specific module (e.g. ?client_id=docqube, ?client_id=inventory, ?client_id=fl)
const searchParams = new URLSearchParams(window.location.search);
const clientId = searchParams.get("client_id");
if (clientId) {
try {
const launchRes = await moduleApi.launchModule(clientId);
const ssoRes = await fetch(launchRes.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", ...launchRes.headers },
body: JSON.stringify(launchRes.payload),
credentials: "include",
});
if (ssoRes.ok && launchRes.redirect_url) {
if (window.top && window.top !== window) {
window.top.location.href = launchRes.redirect_url;
} else {
window.location.href = launchRes.redirect_url;
}
return;
} else {
const errText = await ssoRes.text();
console.error("SSO launch failed:", ssoRes.status, errText);
setErrorMessage(`Failed to complete SSO into ${clientId}: ${ssoRes.statusText || ssoRes.status}`);
return;
}
} catch (ssoErr: any) {
console.error("Auto SSO module launch error:", ssoErr);
setErrorMessage(`Unable to connect to ${clientId} backend server. Please make sure it is running.`);
return;
}
}
navigate("/dashboard");
} catch (error) {
if (error instanceof ApiError && error.mfaRequired) {
+26 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { ExternalLink, Box, Search, Rocket } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { ExternalLink, Box, Search, Rocket, Settings } from 'lucide-react';
import { moduleApi, type Module } from '../modules/user/UserModuleApi';
import { useAuth } from '../../context/AuthContext';
import { useTranslation } from 'react-i18next';
@@ -7,6 +8,8 @@ import CustomInput from '../../components/custom/CustomInput';
const Dashboard = () => {
const { user } = useAuth();
const navigate = useNavigate();
const isSuperAdmin = Boolean(user?.is_superadmin);
const { t } = useTranslation(['dashboard', 'common']);
const [modules, setModules] = useState<Module[]>([]);
@@ -29,15 +32,24 @@ const Dashboard = () => {
}, []);
const handleModuleClick = async (moduleId: string) => {
if (isSuperAdmin) {
navigate(`/admin/modules?edit=${encodeURIComponent(moduleId)}`);
return;
}
try {
const response = await moduleApi.launchModule(moduleId);
await fetch(response.target_url, {
const ssoRes = await fetch(response.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", ...response.headers },
body: JSON.stringify(response.payload),
credentials: "include",
});
if (!ssoRes.ok) {
const errText = await ssoRes.text();
throw new Error(`SSO login failed: ${ssoRes.status} ${errText}`);
}
if (response.redirect_url) {
try {
@@ -165,10 +177,19 @@ const Dashboard = () => {
<div className="pt-4 border-t border-gray-50 mt-auto">
<button
onClick={() => handleModuleClick(module.module_id)}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md"
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md cursor-pointer"
>
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
{t('dashboard:actions.launch')}
{isSuperAdmin ? (
<>
<Settings size={18} className="transition-transform group-hover:rotate-45 duration-200" />
Manage Module
</>
) : (
<>
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
{t('dashboard:actions.launch')}
</>
)}
</button>
</div>
</div>
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Plus, Edit, Trash2, Settings, Shield, Eye, EyeOff } from 'lucide-react';
import { useModuleApi } from '../hooks/useModuleApi';
@@ -11,6 +11,8 @@ import CustomButton from '../../../../components/custom/CustomButton';
const ModuleList = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const editId = searchParams.get('edit');
const { t } = useTranslation(['modules', 'common']);
const { listModules, deleteModule, loading } = useModuleApi();
const [modules, setModules] = useState<Module[]>([]);
@@ -30,6 +32,16 @@ const ModuleList = () => {
fetchModules();
}, []);
useEffect(() => {
if (editId && modules.length > 0) {
const found = modules.find(m => m.id === editId || m.module_id === editId);
if (found) {
setSelectedModule(found);
setShowForm(true);
}
}
}, [editId, modules]);
const handleCreate = () => {
setSelectedModule(null);
setShowForm(true);
@@ -56,6 +68,9 @@ const ModuleList = () => {
const handleFormClose = (saved: boolean) => {
setShowForm(false);
setSelectedModule(null);
if (searchParams.get('edit')) {
setSearchParams({}, { replace: true });
}
if (saved) {
fetchModules();
}
+2 -1
View File
@@ -16,7 +16,8 @@ export const rolesApi = {
getById: (roleId: string) =>
apiClient.get<RoleWithAccesses>(`/api/role/get/${roleId}`),
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
getAccesses: (tenantId?: string) =>
apiClient.get<RoleAccess[]>(`/api/access/get${tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ""}`),
create: (payload: RoleCreateRequest) =>
apiClient.post<Role>("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }),
+37 -47
View File
@@ -40,27 +40,6 @@ const AddRoles = () => {
}
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) {
@@ -128,44 +107,55 @@ const AddRoles = () => {
}
};
loadAccesses();
loadTenants();
return () => {
isMounted = false;
};
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
useEffect(() => {
if (isAuthLoading) return;
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await rolesApi.getAccesses(formData.tenant_id || undefined);
if (isMounted) {
setAccessOptions(data);
const validIds = new Set(data.map((d) => d.id));
setFormData((prev) => ({
...prev,
access_ids: (prev.access_ids || []).filter((id) => validIds.has(id)),
}));
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load access options.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsAccessLoading(false);
}
}
};
loadAccesses();
return () => {
isMounted = false;
};
}, [formData.tenant_id, isAuthLoading]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const availableAccessOptions = useMemo(() => {
if (canReadAllTenants) return accessOptions;
if (!user?.role?.accesses) return [];
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (user.role?.accesses.includes(option.access_code)) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
includedIds.add(current.id);
if (current.parent_id) {
current = accessMap.get(current.parent_id);
} else {
current = undefined;
}
}
}
});
return accessOptions.filter((option) => includedIds.has(option.id));
}, [accessOptions, user, canReadAllTenants]);
return accessOptions;
}, [accessOptions]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+7 -26
View File
@@ -94,31 +94,8 @@ const AllRoles = () => {
const [isDeleting, setIsDeleting] = useState(false);
const availableAccessOptions = useMemo(() => {
if (canReadAllTenants) return accessOptions;
if (!currentUser?.role?.accesses) return [];
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (currentUser.role?.accesses.includes(option.access_code)) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
includedIds.add(current.id);
if (current.parent_id) {
current = accessMap.get(current.parent_id);
} else {
current = undefined;
}
}
}
});
return accessOptions.filter((option) => includedIds.has(option.id));
}, [accessOptions, currentUser, canReadAllTenants]);
return accessOptions;
}, [accessOptions]);
useEffect(() => {
let isMounted = true;
@@ -267,7 +244,11 @@ const AllRoles = () => {
setEditError("");
setIsEditOpen(true);
try {
const details = await rolesApi.getById(role.id);
const [details, tenantAccesses] = await Promise.all([
rolesApi.getById(role.id),
rolesApi.getAccesses(role.tenant_id || undefined),
]);
setAccessOptions(tenantAccesses);
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
@@ -1,105 +1,188 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Layers, ShieldCheck } from "lucide-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);
const { moduleGroups, totalCount } = useMemo(() => {
const ids = new Set(accesses.map((a) => a.id));
const childMap: Record<string, RoleAccess[]> = {};
const roots: RoleAccess[] = [];
accesses.forEach((access) => {
const hasParent = access.parent_id && ids.has(access.parent_id);
if (hasParent) {
if (!childMap[access.parent_id!]) {
childMap[access.parent_id!] = [];
}
childMap[access.parent_id!].push(access);
} else {
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
roots.push(access);
}
});
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
const modGroups: Record<string, Record<string, { roots: RoleAccess[]; count: number }>> = {};
roots.forEach((root) => {
const moduleName = root.module_name || "SaaS (Internal)";
const cat = root.category || "General";
if (!modGroups[moduleName]) {
modGroups[moduleName] = {};
}
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);
});
if (!modGroups[moduleName][cat]) {
modGroups[moduleName][cat] = { roots: [], count: 0 };
}
modGroups[moduleName][cat].roots.push(root);
// Count root and all its children
const countTree = (nodeId: string): number => {
const children = childMap[nodeId] || [];
return 1 + children.reduce((sum, c) => sum + countTree(c.id), 0);
};
modGroups[moduleName][cat].count += countTree(root.id);
});
return categoryGroups;
const sortedModuleNames = Object.keys(modGroups).sort((a, b) => {
if (a === "SaaS (Internal)") return -1;
if (b === "SaaS (Internal)") return 1;
return a.localeCompare(b);
});
const orderedGroups = sortedModuleNames.map((name) => {
const categories = modGroups[name];
const moduleTotal = Object.values(categories).reduce((sum, c) => sum + c.count, 0);
return {
name,
total: moduleTotal,
categories,
};
});
return { moduleGroups: orderedGroups, childMap, totalCount: accesses.length };
}, [accesses]);
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>(() => {
// Expand all by default
const initial: Record<string, boolean> = {};
moduleGroups.forEach((m) => {
initial[m.name] = true;
});
return initial;
});
const toggleModule = (moduleName: string) => {
setExpandedModules((prev) => ({
...prev,
[moduleName]: !prev[moduleName],
}));
};
if (!accesses || accesses.length === 0) {
return <p className="text-sm text-gray-500">No permissions assigned.</p>;
return (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/50 p-6 text-center">
<ShieldCheck className="mx-auto h-8 w-8 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No permissions assigned to this role.</p>
</div>
);
}
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>
)}
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Total Assigned: {totalCount} {totalCount === 1 ? "Permission" : "Permissions"}
</span>
<button
type="button"
onClick={() => {
const allExpanded = moduleGroups.every((m) => expandedModules[m.name]);
const next: Record<string, boolean> = {};
moduleGroups.forEach((m) => {
next[m.name] = !allExpanded;
});
setExpandedModules(next);
}}
className="text-xs font-medium text-blue-600 hover:text-blue-700 underline cursor-pointer"
>
{moduleGroups.every((m) => expandedModules[m.name]) ? "Collapse All" : "Expand All"}
</button>
</div>
{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 className="space-y-3">
{moduleGroups.map((moduleGroup) => {
const isExpanded = expandedModules[moduleGroup.name] ?? true;
return (
<div
key={moduleGroup.name}
className="rounded-lg border border-gray-200 bg-white shadow-xs overflow-hidden transition-all"
>
{/* Module Header */}
<div
onClick={() => toggleModule(moduleGroup.name)}
className="flex items-center justify-between px-4 py-3 bg-gray-50/80 hover:bg-gray-100/80 cursor-pointer border-b border-gray-100 select-none transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="text-gray-400">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</span>
<div className="flex items-center gap-2">
<Layers size={16} className="text-blue-600" />
<h3 className="text-sm font-bold text-gray-900">{moduleGroup.name}</h3>
</div>
</div>
))}
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-700 border border-blue-100">
{moduleGroup.total} {moduleGroup.total === 1 ? "access" : "accesses"}
</span>
</div>
{/* Module Content */}
{isExpanded && (
<div className="p-4 space-y-4">
{Object.entries(moduleGroup.categories).map(([category, { roots }]) => (
<div
key={category}
className="rounded-md border border-gray-100 bg-gray-50/40 p-3"
>
<div className="mb-2.5 flex items-center justify-between border-b border-gray-200/60 pb-1.5">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
{category}
</h4>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{roots.map((root) => (
<div
key={root.id}
className="flex items-start gap-2 rounded bg-white p-2 border border-gray-100 shadow-xs"
>
<span className="mt-1.5 size-1.5 rounded-full bg-blue-500 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-gray-800 block truncate" title={root.name}>
{root.name}
</span>
{root.access_code && (
<span className="text-[10px] text-gray-400 block font-mono">
{root.access_code}
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
);
})}
</div>
</div>
);
};
+39 -21
View File
@@ -555,27 +555,33 @@ const AllUsers = () => {
key: "id",
header: t('columns.actions'),
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.user.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
render: (row) => {
const isSelf = Boolean(currentUser?.id && row.id === currentUser.id);
<ProtectedComponent requiredAccess="admin.user.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
</ProtectedComponent>
return (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.user.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.user.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
<ProtectedComponent requiredAccess="admin.user.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
</ProtectedComponent>
{!isSelf && (
<ProtectedComponent requiredAccess="admin.user.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
</ProtectedComponent>
)}
</CustomActionMenu>
);
},
},
],
[activeSort, emailCounts, emailFilter, getTenantName, getRoleName, hasAccess, nameCounts, nameFilter, openDelete, openEdit, openView, roleCounts, roleFilter, roles, statusCounts, statusFilter, t, tenantCounts, tenantFilter, tenants]
@@ -695,8 +701,20 @@ const AllUsers = () => {
disabled={isRoleLoading || filteredRoles.length === 0}
/>
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
<div className="flex items-center">
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
<div className="flex flex-col gap-1">
<div className="flex items-center">
<CustomCheckBox
label={t('fields.active')}
checked={editForm.status === "active"}
onChange={handleStatusChange}
disabled={Boolean(currentUser?.id && selectedUser?.id === currentUser.id)}
/>
</div>
{Boolean(currentUser?.id && selectedUser?.id === currentUser.id) && (
<span className="text-xs text-amber-600 ml-6">
You cannot deactivate your own account.
</span>
)}
</div>
</div>
{editError && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">{editError}</div>}
+1 -7
View File
@@ -7,7 +7,6 @@ import type {
} from "../application/authentication/AuthTypes";
import {
setAuthCookies,
getAccessToken,
clearAuthCookies,
} from "../lib/authCookies";
import i18n, { loadLanguage } from "../i18n/config";
@@ -33,15 +32,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
const bootstrapAuth = async () => {
const token = getAccessToken();
if (!token) {
setIsLoading(false);
return;
}
try {
const userData = await authApi.me();
setUser(userData);
setAuthCookies(null, true);
const preferred = userData.preferred_language;
if (preferred) {
+7 -5
View File
@@ -17,11 +17,13 @@ const API_TOAST_OPTIONS = { autoClose: 2000 };
const hardLogout = () => {
clearAuthCookies();
toast.error(
"Your session has expired. Please sign in again.",
API_TOAST_OPTIONS
);
window.location.href = "/signin";
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/signin")) {
toast.error(
"Your session has expired. Please sign in again.",
API_TOAST_OPTIONS
);
window.location.href = "/signin";
}
};
const refreshAccessToken = async () => {
+1 -1
View File
@@ -1,5 +1,5 @@
const LOGGED_IN_KEY = "logged_in";
const LOGGED_IN_MAX_AGE_SECONDS = 60 * 15;
const LOGGED_IN_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const buildCookieOptions = (maxAgeSeconds?: number) => {
const options = ["path=/", "samesite=lax"];