Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e7de74472 | ||
|
|
fa0a3f65f4 | ||
|
|
34448b12ae | ||
|
|
862d5cb447 | ||
|
|
5d8c0eae25 | ||
|
|
5aece50053 |
Generated
+10
-22
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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"];
|
||||
|
||||
Reference in New Issue
Block a user