axiodb 22.3.1 → 22.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/electron/electron-builder.json +58 -0
- package/electron/main/main.cts +585 -0
- package/electron/main/preload.cts +51 -0
- package/electron/package-lock.json +8123 -0
- package/electron/package.json +52 -0
- package/electron/public/AXioDB.png +0 -0
- package/electron/resources/after-install.sh +45 -0
- package/electron/resources/after-remove.sh +29 -0
- package/electron/resources/icon.png +0 -0
- package/electron/resources/icons/128x128.png +0 -0
- package/electron/resources/icons/16x16.png +0 -0
- package/electron/resources/icons/24x24.png +0 -0
- package/electron/resources/icons/256x256.png +0 -0
- package/electron/resources/icons/32x32.png +0 -0
- package/electron/resources/icons/48x48.png +0 -0
- package/electron/resources/icons/512x512.png +0 -0
- package/electron/resources/icons/64x64.png +0 -0
- package/electron/src/App.jsx +128 -0
- package/electron/src/api/authApi.js +83 -0
- package/electron/src/api/client.js +102 -0
- package/electron/src/assets/AXioDB.png +0 -0
- package/electron/src/components/auth/CreateRoleModal.jsx +189 -0
- package/electron/src/components/auth/CreateUserModal.jsx +131 -0
- package/electron/src/components/auth/ForcePasswordChangeModal.jsx +132 -0
- package/electron/src/components/auth/ProtectedRoute.jsx +42 -0
- package/electron/src/components/auth/ResetPasswordModal.jsx +90 -0
- package/electron/src/components/auth/UserAvatarMenu.jsx +103 -0
- package/electron/src/components/collection/CreateCollectionModal.jsx +116 -0
- package/electron/src/components/collection/DeleteCollectionModal.jsx +85 -0
- package/electron/src/components/collection/SchemaViewModal.jsx +369 -0
- package/electron/src/components/dashboard/CollectionsChart.jsx +94 -0
- package/electron/src/components/dashboard/DatabaseTreeView.jsx +130 -0
- package/electron/src/components/dashboard/InMemoryCacheCard.jsx +60 -0
- package/electron/src/components/dashboard/StorageDonut.jsx +76 -0
- package/electron/src/components/dashboard/StorageUsageCard.jsx +59 -0
- package/electron/src/components/dashboard/TotalCollectionsCard.jsx +47 -0
- package/electron/src/components/dashboard/TotalDatabasesCard.jsx +43 -0
- package/electron/src/components/dashboard/TotalDocumentsCard.jsx +44 -0
- package/electron/src/components/database/CreateDatabaseModal.jsx +109 -0
- package/electron/src/components/database/DeleteDatabaseModal.jsx +97 -0
- package/electron/src/components/query/CodeEditor.jsx +343 -0
- package/electron/src/components/query/ObjectEditor.jsx +115 -0
- package/electron/src/components/query/ObjectView.jsx +44 -0
- package/electron/src/components/query/QueryEditor.jsx +32 -0
- package/electron/src/components/query/queryLanguage.js +755 -0
- package/electron/src/components/ui/Button.jsx +58 -0
- package/electron/src/components/ui/Card.jsx +29 -0
- package/electron/src/components/ui/ErrorBoundary.jsx +108 -0
- package/electron/src/components/ui/Feedback.jsx +66 -0
- package/electron/src/components/ui/Field.jsx +65 -0
- package/electron/src/components/ui/MetricCard.jsx +104 -0
- package/electron/src/components/ui/Modal.jsx +119 -0
- package/electron/src/components/ui/Page.jsx +40 -0
- package/electron/src/config/key.js +11 -0
- package/electron/src/index.css +181 -0
- package/electron/src/index.html +13 -0
- package/electron/src/layout/Sidebar.jsx +478 -0
- package/electron/src/layout/StatusBar.jsx +70 -0
- package/electron/src/layout/Titlebar.jsx +128 -0
- package/electron/src/main.jsx +42 -0
- package/electron/src/pages/ConnectionHub.jsx +464 -0
- package/electron/src/pages/Dashboard.jsx +128 -0
- package/electron/src/pages/Documents.jsx +823 -0
- package/electron/src/pages/Import.jsx +527 -0
- package/electron/src/pages/UserManagement.jsx +294 -0
- package/electron/src/pages/Welcome.jsx +157 -0
- package/electron/src/store/authStore.js +30 -0
- package/electron/src/store/connectionStore.js +103 -0
- package/electron/src/store/dbStore.js +165 -0
- package/electron/src/store/store.js +8 -0
- package/electron/src/utils/format.js +39 -0
- package/electron/vite.config.js +28 -0
- package/lib/Services/Indexation.operation.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "axiodb-control",
|
|
3
|
+
"productName": "AxioDB Control",
|
|
4
|
+
"version": "22.4.2",
|
|
5
|
+
"description": "Desktop GUI management application for AxioDB",
|
|
6
|
+
"main": "dist-electron/main.cjs",
|
|
7
|
+
"homepage": "https://axiodb.in",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Ankan Saha",
|
|
11
|
+
"email": "dev@axiodb.in"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "concurrently -k \"vite\" \"wait-on tcp:5173 && electron .\"",
|
|
16
|
+
"build:renderer": "vite build",
|
|
17
|
+
"build:main": "tsc -p tsconfig.json",
|
|
18
|
+
"build": "npm run build:renderer && npm run build:main && electron-builder --linux deb AppImage --win nsis --mac zip",
|
|
19
|
+
"dist": "npm run build:renderer && npm run build:main && electron-builder --linux deb AppImage",
|
|
20
|
+
"dist:linux": "npm run build:renderer && npm run build:main && electron-builder --linux deb AppImage",
|
|
21
|
+
"dist:win": "npm run build:renderer && npm run build:main && electron-builder --win nsis",
|
|
22
|
+
"dist:mac": "npm run build:renderer && npm run build:main && electron-builder --mac zip",
|
|
23
|
+
"dist:rpm": "npm run build:renderer && npm run build:main && electron-builder --linux rpm",
|
|
24
|
+
"dist:all": "npm run build:renderer && npm run build:main && electron-builder --linux deb AppImage --win nsis --mac zip",
|
|
25
|
+
"pack": "npm run build:renderer && npm run build:main && electron-builder --dir"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@heroicons/react": "^1.0.6",
|
|
29
|
+
"@tailwindcss/vite": "^4.1.10",
|
|
30
|
+
"axios": "^1.12.0",
|
|
31
|
+
"framer-motion": "^12.23.12",
|
|
32
|
+
"react": "^19.1.0",
|
|
33
|
+
"react-dom": "^19.1.0",
|
|
34
|
+
"react-router-dom": "^7.6.2",
|
|
35
|
+
"recharts": "^2.15.4",
|
|
36
|
+
"tailwindcss": "^4.1.10",
|
|
37
|
+
"zustand": "^5.0.6"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.13.0",
|
|
41
|
+
"@types/react": "^19.1.2",
|
|
42
|
+
"@types/react-dom": "^19.1.2",
|
|
43
|
+
"@vitejs/plugin-react-swc": "^3.9.0",
|
|
44
|
+
"concurrently": "^9.1.2",
|
|
45
|
+
"cross-env": "^7.0.3",
|
|
46
|
+
"electron": "^34.2.0",
|
|
47
|
+
"electron-builder": "^25.1.8",
|
|
48
|
+
"typescript": "^5.8.0",
|
|
49
|
+
"vite": "^6.3.5",
|
|
50
|
+
"wait-on": "^8.0.2"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Create command line link in /usr/bin
|
|
5
|
+
if type update-alternatives 2>/dev/null >&1; then
|
|
6
|
+
if [ -L '/usr/bin/axiodb-control' -a -e '/usr/bin/axiodb-control' -a "`readlink '/usr/bin/axiodb-control'`" != '/etc/alternatives/axiodb-control' ]; then
|
|
7
|
+
rm -f '/usr/bin/axiodb-control'
|
|
8
|
+
fi
|
|
9
|
+
update-alternatives --install '/usr/bin/axiodb-control' 'axiodb-control' '/opt/AxioDB Control/axiodb-control' 100 || ln -sf '/opt/AxioDB Control/axiodb-control' '/usr/bin/axiodb-control'
|
|
10
|
+
else
|
|
11
|
+
ln -sf '/opt/AxioDB Control/axiodb-control' '/usr/bin/axiodb-control'
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# Ensure chrome-sandbox permissions
|
|
15
|
+
if [ -f '/opt/AxioDB Control/chrome-sandbox' ]; then
|
|
16
|
+
if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then
|
|
17
|
+
chmod 4755 '/opt/AxioDB Control/chrome-sandbox' || true
|
|
18
|
+
else
|
|
19
|
+
chmod 0755 '/opt/AxioDB Control/chrome-sandbox' || true
|
|
20
|
+
fi
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Ensure authentic regular PNG file in /usr/share/pixmaps/ with standard read permissions
|
|
24
|
+
if [ -d /usr/share/pixmaps ]; then
|
|
25
|
+
if [ -f /usr/share/icons/hicolor/512x512/apps/axiodb-control.png ]; then
|
|
26
|
+
cp -f /usr/share/icons/hicolor/512x512/apps/axiodb-control.png /usr/share/pixmaps/axiodb-control.png || true
|
|
27
|
+
elif [ -f "/opt/AxioDB Control/resources/icon.png" ]; then
|
|
28
|
+
cp -f "/opt/AxioDB Control/resources/icon.png" /usr/share/pixmaps/axiodb-control.png || true
|
|
29
|
+
fi
|
|
30
|
+
chmod 644 /usr/share/pixmaps/axiodb-control.png 2>/dev/null || true
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Update icon caches so GNOME/KDE/XFCE/Cinnamon immediately recognizes the app icon
|
|
34
|
+
if hash gtk-update-icon-cache 2>/dev/null; then
|
|
35
|
+
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Update mime and desktop databases
|
|
39
|
+
if hash update-mime-database 2>/dev/null; then
|
|
40
|
+
update-mime-database /usr/share/mime || true
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if hash update-desktop-database 2>/dev/null; then
|
|
44
|
+
update-desktop-database /usr/share/applications || true
|
|
45
|
+
fi
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Remove /usr/bin link
|
|
5
|
+
if type update-alternatives 2>/dev/null >&1; then
|
|
6
|
+
update-alternatives --remove 'axiodb-control' '/opt/AxioDB Control/axiodb-control' || true
|
|
7
|
+
fi
|
|
8
|
+
if [ -L '/usr/bin/axiodb-control' ]; then
|
|
9
|
+
rm -f '/usr/bin/axiodb-control' || true
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
# Remove pixmap file or symlink if present
|
|
13
|
+
if [ -e /usr/share/pixmaps/axiodb-control.png ] || [ -L /usr/share/pixmaps/axiodb-control.png ]; then
|
|
14
|
+
rm -f /usr/share/pixmaps/axiodb-control.png || true
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Update icon caches
|
|
18
|
+
if hash gtk-update-icon-cache 2>/dev/null; then
|
|
19
|
+
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# Update mime and desktop database
|
|
23
|
+
if hash update-mime-database 2>/dev/null; then
|
|
24
|
+
update-mime-database /usr/share/mime || true
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
if hash update-desktop-database 2>/dev/null; then
|
|
28
|
+
update-desktop-database /usr/share/applications || true
|
|
29
|
+
fi
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
|
3
|
+
import Titlebar from "./layout/Titlebar";
|
|
4
|
+
import Sidebar from "./layout/Sidebar";
|
|
5
|
+
import StatusBar from "./layout/StatusBar";
|
|
6
|
+
import Welcome from "./pages/Welcome";
|
|
7
|
+
import ConnectionHub from "./pages/ConnectionHub";
|
|
8
|
+
import Documents from "./pages/Documents";
|
|
9
|
+
import Dashboard from "./pages/Dashboard";
|
|
10
|
+
import Import from "./pages/Import";
|
|
11
|
+
import UserManagement from "./pages/UserManagement";
|
|
12
|
+
import ForcePasswordChangeModal from "./components/auth/ForcePasswordChangeModal";
|
|
13
|
+
import ErrorBoundary from "./components/ui/ErrorBoundary";
|
|
14
|
+
import { useConnectionStore } from "./store/connectionStore";
|
|
15
|
+
import { useAuthStore } from "./store/authStore";
|
|
16
|
+
import authApi from "./api/authApi";
|
|
17
|
+
|
|
18
|
+
const MainLayout = ({ children }) => {
|
|
19
|
+
const location = useLocation();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex-1 flex overflow-hidden bg-slate-50 text-slate-800">
|
|
23
|
+
<Sidebar />
|
|
24
|
+
<main
|
|
25
|
+
key={location.pathname}
|
|
26
|
+
className="flex-1 overflow-hidden bg-slate-50 flex flex-col animate-fadeIn min-w-0"
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const AppContent = () => {
|
|
35
|
+
const { hasSeenWelcome, setHasSeenWelcome, isConnected, setPingLatency, getBaseUrl } = useConnectionStore();
|
|
36
|
+
const { mustChangePassword } = useAuthStore();
|
|
37
|
+
const [showForcePassword, setShowForcePassword] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (mustChangePassword) {
|
|
41
|
+
setShowForcePassword(true);
|
|
42
|
+
}
|
|
43
|
+
}, [mustChangePassword]);
|
|
44
|
+
|
|
45
|
+
// Periodic health ping every 10 seconds while connected — keeps the
|
|
46
|
+
// "connected X ms" latency in the titlebar and status bar fresh.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isConnected) return;
|
|
49
|
+
|
|
50
|
+
const ping = async () => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await authApi.ping(getBaseUrl());
|
|
53
|
+
setPingLatency(res.latency);
|
|
54
|
+
} catch {
|
|
55
|
+
setPingLatency(null);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
ping();
|
|
60
|
+
const interval = setInterval(ping, 10000);
|
|
61
|
+
return () => clearInterval(interval);
|
|
62
|
+
}, [isConnected, getBaseUrl, setPingLatency]);
|
|
63
|
+
|
|
64
|
+
// First launch: Animated Welcome
|
|
65
|
+
if (!hasSeenWelcome) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="h-screen flex flex-col bg-slate-50 overflow-hidden text-slate-900">
|
|
68
|
+
<Titlebar />
|
|
69
|
+
<Welcome onGetStarted={() => setHasSeenWelcome(true)} />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Offline: Connection Hub
|
|
75
|
+
if (!isConnected) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="h-screen flex flex-col bg-slate-50 overflow-hidden text-slate-900">
|
|
78
|
+
<Titlebar />
|
|
79
|
+
<ConnectionHub
|
|
80
|
+
onConnected={() => {}}
|
|
81
|
+
onForcePasswordChange={() => setShowForcePassword(true)}
|
|
82
|
+
/>
|
|
83
|
+
<ForcePasswordChangeModal
|
|
84
|
+
isOpen={showForcePassword}
|
|
85
|
+
onSuccess={() => setShowForcePassword(false)}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Connected: Full Native Desktop Workspace
|
|
92
|
+
return (
|
|
93
|
+
<div className="h-screen flex flex-col bg-slate-50 overflow-hidden text-slate-900">
|
|
94
|
+
<Titlebar />
|
|
95
|
+
|
|
96
|
+
<ErrorBoundary>
|
|
97
|
+
<MainLayout>
|
|
98
|
+
<Routes>
|
|
99
|
+
<Route path="/" element={<Documents />} />
|
|
100
|
+
<Route path="/metrics" element={<Dashboard />} />
|
|
101
|
+
<Route path="/import" element={<Import />} />
|
|
102
|
+
<Route path="/users" element={<UserManagement />} />
|
|
103
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
104
|
+
</Routes>
|
|
105
|
+
</MainLayout>
|
|
106
|
+
</ErrorBoundary>
|
|
107
|
+
|
|
108
|
+
<StatusBar />
|
|
109
|
+
|
|
110
|
+
<ForcePasswordChangeModal
|
|
111
|
+
isOpen={showForcePassword}
|
|
112
|
+
onSuccess={() => setShowForcePassword(false)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function App() {
|
|
119
|
+
return (
|
|
120
|
+
<ErrorBoundary>
|
|
121
|
+
<Router>
|
|
122
|
+
<AppContent />
|
|
123
|
+
</Router>
|
|
124
|
+
</ErrorBoundary>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default App;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import apiClient from "./client";
|
|
2
|
+
import { useConnectionStore } from "../store/connectionStore";
|
|
3
|
+
|
|
4
|
+
const authApi = {
|
|
5
|
+
// Liveness & latency check
|
|
6
|
+
ping: async (targetBaseUrl) => {
|
|
7
|
+
const start = performance.now();
|
|
8
|
+
const url = targetBaseUrl ? `${targetBaseUrl.replace(/\/+$/, "")}/api/health` : "/api/health";
|
|
9
|
+
const res = await apiClient.get(url);
|
|
10
|
+
const latency = Math.max(1, Math.round(performance.now() - start));
|
|
11
|
+
const resultObj = { latency, success: true, ...(res.data || {}) };
|
|
12
|
+
return { ...res, ...resultObj };
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
// Login supporting both (username, password) and ({ username, password })
|
|
16
|
+
login: async (usernameOrObj, maybePassword) => {
|
|
17
|
+
let username = usernameOrObj;
|
|
18
|
+
let password = maybePassword;
|
|
19
|
+
if (typeof usernameOrObj === "object" && usernameOrObj !== null) {
|
|
20
|
+
username = usernameOrObj.username;
|
|
21
|
+
password = usernameOrObj.password;
|
|
22
|
+
}
|
|
23
|
+
const res = await apiClient.post("/api/auth/login", { username, password });
|
|
24
|
+
const userData = res.data?.data || {};
|
|
25
|
+
return { ...res, ...userData };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
logout: () => apiClient.post("/api/auth/logout"),
|
|
29
|
+
fetchMe: () => apiClient.get("/api/auth/me"),
|
|
30
|
+
changePassword: (currentPassword, newPassword) =>
|
|
31
|
+
apiClient.patch("/api/auth/change-password", { currentPassword, newPassword }),
|
|
32
|
+
|
|
33
|
+
listUsers: () => apiClient.get("/api/auth/users"),
|
|
34
|
+
createUser: (username, password, role) =>
|
|
35
|
+
apiClient.post("/api/auth/users", { username, password, role }),
|
|
36
|
+
updateUserRole: (username, role) =>
|
|
37
|
+
apiClient.patch(`/api/auth/users/${encodeURIComponent(username)}/role`, { role }),
|
|
38
|
+
resetUserPassword: (username, newPassword) =>
|
|
39
|
+
apiClient.patch(`/api/auth/users/${encodeURIComponent(username)}/reset-password`, { newPassword }),
|
|
40
|
+
deleteUser: (username) => apiClient.delete(`/api/auth/users/${encodeURIComponent(username)}`),
|
|
41
|
+
|
|
42
|
+
listRoles: () => apiClient.get("/api/auth/roles"),
|
|
43
|
+
createRole: (roleName, permissions) =>
|
|
44
|
+
apiClient.post("/api/auth/roles", { roleName, permissions }),
|
|
45
|
+
listPermissions: () => apiClient.get("/api/auth/roles/permissions"),
|
|
46
|
+
|
|
47
|
+
// Direct probe alias
|
|
48
|
+
checkHealth: (targetBaseUrl) => authApi.ping(targetBaseUrl),
|
|
49
|
+
|
|
50
|
+
// Export a database as a tar.gz archive — uses native IPC in Electron for binary
|
|
51
|
+
// streaming, with a browser blob-download fallback for web dev.
|
|
52
|
+
exportDatabase: async (dbName) => {
|
|
53
|
+
const baseUrl = useConnectionStore.getState().getBaseUrl();
|
|
54
|
+
|
|
55
|
+
if (typeof window !== "undefined" && window.electronAPI?.exportDatabase) {
|
|
56
|
+
return window.electronAPI.exportDatabase(dbName, baseUrl);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const response = await apiClient.get("/api/db/export-database/", {
|
|
60
|
+
params: { dbName },
|
|
61
|
+
responseType: "blob",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const disposition = response.headers["content-disposition"];
|
|
65
|
+
const match = disposition?.match(/filename="(.+)"/);
|
|
66
|
+
const downloadName = match ? match[1] : `${dbName}.tar.gz`;
|
|
67
|
+
|
|
68
|
+
const url = window.URL.createObjectURL(new Blob([response.data], {
|
|
69
|
+
type: response.headers["content-type"] || "application/gzip",
|
|
70
|
+
}));
|
|
71
|
+
const link = document.createElement("a");
|
|
72
|
+
link.href = url;
|
|
73
|
+
link.setAttribute("download", downloadName);
|
|
74
|
+
document.body.appendChild(link);
|
|
75
|
+
link.click();
|
|
76
|
+
link.remove();
|
|
77
|
+
window.URL.revokeObjectURL(url);
|
|
78
|
+
|
|
79
|
+
return { canceled: false, success: true, filePath: downloadName };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default authApi;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { useConnectionStore } from "../store/connectionStore";
|
|
3
|
+
import { useAuthStore } from "../store/authStore";
|
|
4
|
+
|
|
5
|
+
const sanitizeHeaders = (rawHeaders) => {
|
|
6
|
+
const result = {};
|
|
7
|
+
if (!rawHeaders) return result;
|
|
8
|
+
|
|
9
|
+
const headersObj = typeof rawHeaders.toJSON === "function" ? rawHeaders.toJSON() : rawHeaders;
|
|
10
|
+
|
|
11
|
+
for (const [key, val] of Object.entries(headersObj)) {
|
|
12
|
+
if (val !== undefined && val !== null && typeof val !== "object") {
|
|
13
|
+
result[key] = String(val);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const customIpcAdapter = async (config) => {
|
|
20
|
+
if (typeof window !== "undefined" && window.electronAPI?.request) {
|
|
21
|
+
let fullUrl = config.url;
|
|
22
|
+
if (!fullUrl.startsWith("http://") && !fullUrl.startsWith("https://")) {
|
|
23
|
+
const baseUrl = config.baseURL || useConnectionStore.getState().getBaseUrl();
|
|
24
|
+
fullUrl = `${baseUrl.replace(/\/+$/, "")}/${fullUrl.replace(/^\/+/, "")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const res = await window.electronAPI.request({
|
|
29
|
+
url: fullUrl,
|
|
30
|
+
method: config.method || "GET",
|
|
31
|
+
headers: sanitizeHeaders(config.headers),
|
|
32
|
+
data: config.data,
|
|
33
|
+
params: config.params,
|
|
34
|
+
timeout: config.timeout,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const response = {
|
|
38
|
+
data: res.data,
|
|
39
|
+
status: res.status,
|
|
40
|
+
statusText: res.statusText,
|
|
41
|
+
headers: res.headers,
|
|
42
|
+
config,
|
|
43
|
+
request: {},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const validate = config.validateStatus || ((status) => status >= 200 && status < 300);
|
|
47
|
+
if (validate(res.status)) {
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const message =
|
|
52
|
+
res.data && typeof res.data === "object" && res.data.message
|
|
53
|
+
? String(res.data.message)
|
|
54
|
+
: `Request failed with status code ${res.status}`;
|
|
55
|
+
|
|
56
|
+
const error = new axios.AxiosError(
|
|
57
|
+
message,
|
|
58
|
+
res.status === 401 ? "ERR_BAD_REQUEST" : undefined,
|
|
59
|
+
config,
|
|
60
|
+
{},
|
|
61
|
+
response
|
|
62
|
+
);
|
|
63
|
+
return Promise.reject(error);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return Promise.reject(err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fallback to standard fetch/xhr in browser dev
|
|
70
|
+
return axios.defaults.adapter(config);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const apiClient = axios.create({
|
|
74
|
+
adapter: customIpcAdapter,
|
|
75
|
+
withCredentials: true,
|
|
76
|
+
timeout: 30000,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
apiClient.interceptors.request.use((config) => {
|
|
80
|
+
const baseUrl = useConnectionStore.getState().getBaseUrl();
|
|
81
|
+
config.baseURL = baseUrl;
|
|
82
|
+
return config;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
apiClient.interceptors.response.use(
|
|
86
|
+
(response) => response,
|
|
87
|
+
(error) => {
|
|
88
|
+
if (error.response?.status === 401) {
|
|
89
|
+
const isLogin = error.config?.url?.includes("/api/auth/login");
|
|
90
|
+
if (!isLogin) {
|
|
91
|
+
useAuthStore.getState().clearSession();
|
|
92
|
+
useConnectionStore.getState().setConnectionError(
|
|
93
|
+
error.response?.data?.message || "Session expired or unauthorized (401). Please log in again."
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return Promise.reject(error);
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export default apiClient;
|
|
102
|
+
export { customIpcAdapter, sanitizeHeaders };
|
|
Binary file
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import authApi from '../../api/authApi'
|
|
3
|
+
import Modal from '../ui/Modal'
|
|
4
|
+
import Button from '../ui/Button'
|
|
5
|
+
import { Input } from '../ui/Field'
|
|
6
|
+
import { Alert, Badge, Skeleton } from '../ui/Feedback'
|
|
7
|
+
|
|
8
|
+
const CreateRoleModal = ({ isOpen, onClose, onRoleCreated }) => {
|
|
9
|
+
const [roleName, setRoleName] = useState('')
|
|
10
|
+
const [permissionCatalogue, setPermissionCatalogue] = useState([])
|
|
11
|
+
const [selectedPermissions, setSelectedPermissions] = useState([])
|
|
12
|
+
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false)
|
|
13
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
14
|
+
const [error, setError] = useState('')
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!isOpen) return
|
|
18
|
+
|
|
19
|
+
setIsLoadingPermissions(true)
|
|
20
|
+
authApi
|
|
21
|
+
.listPermissions()
|
|
22
|
+
.then((response) => setPermissionCatalogue(response.data.data || []))
|
|
23
|
+
.catch(() => setError('Failed to load permission catalogue'))
|
|
24
|
+
.finally(() => setIsLoadingPermissions(false))
|
|
25
|
+
}, [isOpen])
|
|
26
|
+
|
|
27
|
+
const handleClose = () => {
|
|
28
|
+
setRoleName('')
|
|
29
|
+
setSelectedPermissions([])
|
|
30
|
+
setError('')
|
|
31
|
+
setIsSubmitting(false)
|
|
32
|
+
onClose()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const togglePermission = (key) => {
|
|
36
|
+
setSelectedPermissions((prev) =>
|
|
37
|
+
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const toggleGroup = (groupKeys, allSelected) => {
|
|
42
|
+
setSelectedPermissions((prev) => {
|
|
43
|
+
if (allSelected) {
|
|
44
|
+
return prev.filter((p) => !groupKeys.includes(p))
|
|
45
|
+
}
|
|
46
|
+
return [...new Set([...prev, ...groupKeys])]
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const groupedPermissions = permissionCatalogue.reduce((acc, perm) => {
|
|
51
|
+
acc[perm.group] = acc[perm.group] || []
|
|
52
|
+
acc[perm.group].push(perm)
|
|
53
|
+
return acc
|
|
54
|
+
}, {})
|
|
55
|
+
|
|
56
|
+
const handleSubmit = async (e) => {
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
|
|
59
|
+
if (!roleName.trim()) {
|
|
60
|
+
setError('Role name is required')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (selectedPermissions.length === 0) {
|
|
64
|
+
setError('Select at least one permission')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setIsSubmitting(true)
|
|
69
|
+
setError('')
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await authApi.createRole(roleName, selectedPermissions)
|
|
73
|
+
onRoleCreated()
|
|
74
|
+
handleClose()
|
|
75
|
+
} catch (err) {
|
|
76
|
+
setError(err.response?.data?.message || 'Failed to create role. Please try again.')
|
|
77
|
+
setIsSubmitting(false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ShieldIcon = (
|
|
82
|
+
<svg className='h-5 w-5' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={2}>
|
|
83
|
+
<path strokeLinecap='round' strokeLinejoin='round' d='M9 12l2 2 4-4M12 3l7 4v5c0 4.42-3.05 8.56-7 9.75C8.05 20.56 5 16.42 5 12V7l7-4z' />
|
|
84
|
+
</svg>
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Modal
|
|
89
|
+
isOpen={isOpen}
|
|
90
|
+
onClose={handleClose}
|
|
91
|
+
title='Create role'
|
|
92
|
+
subtitle={`${selectedPermissions.length} permission${selectedPermissions.length === 1 ? '' : 's'} selected`}
|
|
93
|
+
icon={ShieldIcon}
|
|
94
|
+
size='lg'
|
|
95
|
+
footer={
|
|
96
|
+
<>
|
|
97
|
+
<Button variant='secondary' onClick={handleClose} disabled={isSubmitting}>
|
|
98
|
+
Cancel
|
|
99
|
+
</Button>
|
|
100
|
+
<Button
|
|
101
|
+
onClick={handleSubmit}
|
|
102
|
+
loading={isSubmitting}
|
|
103
|
+
disabled={!roleName.trim() || selectedPermissions.length === 0}
|
|
104
|
+
>
|
|
105
|
+
Create role
|
|
106
|
+
</Button>
|
|
107
|
+
</>
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<form onSubmit={handleSubmit} className='space-y-5'>
|
|
111
|
+
<Input
|
|
112
|
+
label='Role name'
|
|
113
|
+
value={roleName}
|
|
114
|
+
onChange={(event) => setRoleName(event.target.value)}
|
|
115
|
+
placeholder='Auditor'
|
|
116
|
+
autoFocus
|
|
117
|
+
autoComplete='off'
|
|
118
|
+
hint='Shown wherever roles are assigned. Choose something that describes the job, not the person.'
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<div>
|
|
122
|
+
<p className='mb-2 text-sm font-medium text-ink-700'>Permissions</p>
|
|
123
|
+
|
|
124
|
+
{isLoadingPermissions
|
|
125
|
+
? (
|
|
126
|
+
<div className='space-y-2'>
|
|
127
|
+
{[0, 1, 2].map((i) => <Skeleton key={i} className='h-20 w-full' />)}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
: (
|
|
131
|
+
<div className='space-y-3'>
|
|
132
|
+
{Object.entries(groupedPermissions).map(([group, permissions]) => {
|
|
133
|
+
const groupKeys = permissions.map((permission) => permission.key)
|
|
134
|
+
const allSelected = groupKeys.every((key) => selectedPermissions.includes(key))
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div key={group} className='rounded-lg border border-ink-200 bg-white'>
|
|
138
|
+
<div className='flex items-center justify-between border-b border-ink-100 px-4 py-2.5'>
|
|
139
|
+
<span className='text-sm font-semibold text-ink-900'>{group}</span>
|
|
140
|
+
<button
|
|
141
|
+
type='button'
|
|
142
|
+
onClick={() => toggleGroup(groupKeys, allSelected)}
|
|
143
|
+
className='text-xs font-medium text-brand-700 transition-colors hover:text-brand-800'
|
|
144
|
+
>
|
|
145
|
+
{allSelected ? 'Clear all' : 'Select all'}
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
<div className='grid gap-1 p-2 sm:grid-cols-2'>
|
|
149
|
+
{permissions.map((permission) => (
|
|
150
|
+
<label
|
|
151
|
+
key={permission.key}
|
|
152
|
+
className='flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors hover:bg-ink-50'
|
|
153
|
+
>
|
|
154
|
+
<input
|
|
155
|
+
type='checkbox'
|
|
156
|
+
checked={selectedPermissions.includes(permission.key)}
|
|
157
|
+
onChange={() => togglePermission(permission.key)}
|
|
158
|
+
className='mt-0.5 h-4 w-4 shrink-0 rounded border-ink-300 text-brand-600 focus:ring-brand-500'
|
|
159
|
+
/>
|
|
160
|
+
<span className='min-w-0'>
|
|
161
|
+
<span className='block truncate text-sm text-ink-800'>
|
|
162
|
+
{permission.description}
|
|
163
|
+
</span>
|
|
164
|
+
<span className='block truncate font-mono text-[11px] text-ink-400'>
|
|
165
|
+
{permission.key}
|
|
166
|
+
</span>
|
|
167
|
+
</span>
|
|
168
|
+
</label>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{selectedPermissions.length > 0 && (
|
|
179
|
+
<Badge tone='brand'>{selectedPermissions.length} selected</Badge>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{error && <Alert tone='danger'>{error}</Alert>}
|
|
183
|
+
<button type='submit' className='hidden' aria-hidden='true' tabIndex={-1} />
|
|
184
|
+
</form>
|
|
185
|
+
</Modal>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default CreateRoleModal
|