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.
Files changed (74) hide show
  1. package/electron/electron-builder.json +58 -0
  2. package/electron/main/main.cts +585 -0
  3. package/electron/main/preload.cts +51 -0
  4. package/electron/package-lock.json +8123 -0
  5. package/electron/package.json +52 -0
  6. package/electron/public/AXioDB.png +0 -0
  7. package/electron/resources/after-install.sh +45 -0
  8. package/electron/resources/after-remove.sh +29 -0
  9. package/electron/resources/icon.png +0 -0
  10. package/electron/resources/icons/128x128.png +0 -0
  11. package/electron/resources/icons/16x16.png +0 -0
  12. package/electron/resources/icons/24x24.png +0 -0
  13. package/electron/resources/icons/256x256.png +0 -0
  14. package/electron/resources/icons/32x32.png +0 -0
  15. package/electron/resources/icons/48x48.png +0 -0
  16. package/electron/resources/icons/512x512.png +0 -0
  17. package/electron/resources/icons/64x64.png +0 -0
  18. package/electron/src/App.jsx +128 -0
  19. package/electron/src/api/authApi.js +83 -0
  20. package/electron/src/api/client.js +102 -0
  21. package/electron/src/assets/AXioDB.png +0 -0
  22. package/electron/src/components/auth/CreateRoleModal.jsx +189 -0
  23. package/electron/src/components/auth/CreateUserModal.jsx +131 -0
  24. package/electron/src/components/auth/ForcePasswordChangeModal.jsx +132 -0
  25. package/electron/src/components/auth/ProtectedRoute.jsx +42 -0
  26. package/electron/src/components/auth/ResetPasswordModal.jsx +90 -0
  27. package/electron/src/components/auth/UserAvatarMenu.jsx +103 -0
  28. package/electron/src/components/collection/CreateCollectionModal.jsx +116 -0
  29. package/electron/src/components/collection/DeleteCollectionModal.jsx +85 -0
  30. package/electron/src/components/collection/SchemaViewModal.jsx +369 -0
  31. package/electron/src/components/dashboard/CollectionsChart.jsx +94 -0
  32. package/electron/src/components/dashboard/DatabaseTreeView.jsx +130 -0
  33. package/electron/src/components/dashboard/InMemoryCacheCard.jsx +60 -0
  34. package/electron/src/components/dashboard/StorageDonut.jsx +76 -0
  35. package/electron/src/components/dashboard/StorageUsageCard.jsx +59 -0
  36. package/electron/src/components/dashboard/TotalCollectionsCard.jsx +47 -0
  37. package/electron/src/components/dashboard/TotalDatabasesCard.jsx +43 -0
  38. package/electron/src/components/dashboard/TotalDocumentsCard.jsx +44 -0
  39. package/electron/src/components/database/CreateDatabaseModal.jsx +109 -0
  40. package/electron/src/components/database/DeleteDatabaseModal.jsx +97 -0
  41. package/electron/src/components/query/CodeEditor.jsx +343 -0
  42. package/electron/src/components/query/ObjectEditor.jsx +115 -0
  43. package/electron/src/components/query/ObjectView.jsx +44 -0
  44. package/electron/src/components/query/QueryEditor.jsx +32 -0
  45. package/electron/src/components/query/queryLanguage.js +755 -0
  46. package/electron/src/components/ui/Button.jsx +58 -0
  47. package/electron/src/components/ui/Card.jsx +29 -0
  48. package/electron/src/components/ui/ErrorBoundary.jsx +108 -0
  49. package/electron/src/components/ui/Feedback.jsx +66 -0
  50. package/electron/src/components/ui/Field.jsx +65 -0
  51. package/electron/src/components/ui/MetricCard.jsx +104 -0
  52. package/electron/src/components/ui/Modal.jsx +119 -0
  53. package/electron/src/components/ui/Page.jsx +40 -0
  54. package/electron/src/config/key.js +11 -0
  55. package/electron/src/index.css +181 -0
  56. package/electron/src/index.html +13 -0
  57. package/electron/src/layout/Sidebar.jsx +478 -0
  58. package/electron/src/layout/StatusBar.jsx +70 -0
  59. package/electron/src/layout/Titlebar.jsx +128 -0
  60. package/electron/src/main.jsx +42 -0
  61. package/electron/src/pages/ConnectionHub.jsx +464 -0
  62. package/electron/src/pages/Dashboard.jsx +128 -0
  63. package/electron/src/pages/Documents.jsx +823 -0
  64. package/electron/src/pages/Import.jsx +527 -0
  65. package/electron/src/pages/UserManagement.jsx +294 -0
  66. package/electron/src/pages/Welcome.jsx +157 -0
  67. package/electron/src/store/authStore.js +30 -0
  68. package/electron/src/store/connectionStore.js +103 -0
  69. package/electron/src/store/dbStore.js +165 -0
  70. package/electron/src/store/store.js +8 -0
  71. package/electron/src/utils/format.js +39 -0
  72. package/electron/vite.config.js +28 -0
  73. package/lib/Services/Indexation.operation.js +1 -1
  74. 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
@@ -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