@checkstack/catalog-frontend 0.1.0 → 0.3.0
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/CHANGELOG.md +144 -0
- package/package.json +1 -1
- package/src/api.ts +2 -10
- package/src/components/CatalogConfigPage.tsx +166 -126
- package/src/components/CatalogPage.tsx +2 -4
- package/src/components/SystemDetailPage.tsx +78 -75
- package/src/components/UserMenuItems.tsx +5 -7
- package/src/index.tsx +4 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,149 @@
|
|
|
1
1
|
# @checkstack/catalog-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7a23261: ## TanStack Query Integration
|
|
8
|
+
|
|
9
|
+
Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
|
|
10
|
+
|
|
11
|
+
### New Features
|
|
12
|
+
|
|
13
|
+
- **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
|
|
14
|
+
- **Automatic request deduplication**: Multiple components requesting the same data share a single network request
|
|
15
|
+
- **Built-in caching**: Configurable stale time and cache duration per query
|
|
16
|
+
- **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
|
|
17
|
+
- **Background refetching**: Stale data is automatically refreshed when components mount
|
|
18
|
+
|
|
19
|
+
### Contract Changes
|
|
20
|
+
|
|
21
|
+
All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
const getItems = proc()
|
|
25
|
+
.meta({ operationType: "query", access: [access.read] })
|
|
26
|
+
.output(z.array(itemSchema))
|
|
27
|
+
.query();
|
|
28
|
+
|
|
29
|
+
const createItem = proc()
|
|
30
|
+
.meta({ operationType: "mutation", access: [access.manage] })
|
|
31
|
+
.input(createItemSchema)
|
|
32
|
+
.output(itemSchema)
|
|
33
|
+
.mutation();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Migration
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Before (forPlugin pattern)
|
|
40
|
+
const api = useApi(myPluginApiRef);
|
|
41
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
api.getItems().then(setItems);
|
|
44
|
+
}, [api]);
|
|
45
|
+
|
|
46
|
+
// After (usePluginClient pattern)
|
|
47
|
+
const client = usePluginClient(MyPluginApi);
|
|
48
|
+
const { data: items, isLoading } = client.getItems.useQuery({});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Bug Fixes
|
|
52
|
+
|
|
53
|
+
- Fixed `rpc.test.ts` test setup for middleware type inference
|
|
54
|
+
- Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
|
|
55
|
+
- Fixed null→undefined warnings in notification and queue frontends
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [7a23261]
|
|
60
|
+
- @checkstack/frontend-api@0.2.0
|
|
61
|
+
- @checkstack/common@0.3.0
|
|
62
|
+
- @checkstack/auth-frontend@0.3.0
|
|
63
|
+
- @checkstack/catalog-common@1.2.0
|
|
64
|
+
- @checkstack/notification-common@0.2.0
|
|
65
|
+
- @checkstack/ui@0.2.1
|
|
66
|
+
|
|
67
|
+
## 0.2.0
|
|
68
|
+
|
|
69
|
+
### Minor Changes
|
|
70
|
+
|
|
71
|
+
- 9faec1f: # Unified AccessRule Terminology Refactoring
|
|
72
|
+
|
|
73
|
+
This release completes a comprehensive terminology refactoring from "permission" to "accessRule" across the entire codebase, establishing a consistent and modern access control vocabulary.
|
|
74
|
+
|
|
75
|
+
## Changes
|
|
76
|
+
|
|
77
|
+
### Core Infrastructure (`@checkstack/common`)
|
|
78
|
+
|
|
79
|
+
- Introduced `AccessRule` interface as the primary access control type
|
|
80
|
+
- Added `accessPair()` helper for creating read/manage access rule pairs
|
|
81
|
+
- Added `access()` builder for individual access rules
|
|
82
|
+
- Replaced `Permission` type with `AccessRule` throughout
|
|
83
|
+
|
|
84
|
+
### API Changes
|
|
85
|
+
|
|
86
|
+
- `env.registerPermissions()` → `env.registerAccessRules()`
|
|
87
|
+
- `meta.permissions` → `meta.access` in RPC contracts
|
|
88
|
+
- `usePermission()` → `useAccess()` in frontend hooks
|
|
89
|
+
- Route `permission:` field → `accessRule:` field
|
|
90
|
+
|
|
91
|
+
### UI Changes
|
|
92
|
+
|
|
93
|
+
- "Roles & Permissions" tab → "Roles & Access Rules"
|
|
94
|
+
- "You don't have permission..." → "You don't have access..."
|
|
95
|
+
- All permission-related UI text updated
|
|
96
|
+
|
|
97
|
+
### Documentation & Templates
|
|
98
|
+
|
|
99
|
+
- Updated 18 documentation files with AccessRule terminology
|
|
100
|
+
- Updated 7 scaffolding templates with `accessPair()` pattern
|
|
101
|
+
- All code examples use new AccessRule API
|
|
102
|
+
|
|
103
|
+
## Migration Guide
|
|
104
|
+
|
|
105
|
+
### Backend Plugins
|
|
106
|
+
|
|
107
|
+
```diff
|
|
108
|
+
- import { permissionList } from "./permissions";
|
|
109
|
+
- env.registerPermissions(permissionList);
|
|
110
|
+
+ import { accessRules } from "./access";
|
|
111
|
+
+ env.registerAccessRules(accessRules);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### RPC Contracts
|
|
115
|
+
|
|
116
|
+
```diff
|
|
117
|
+
- .meta({ userType: "user", permissions: [permissions.read.id] })
|
|
118
|
+
+ .meta({ userType: "user", access: [access.read] })
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Frontend Hooks
|
|
122
|
+
|
|
123
|
+
```diff
|
|
124
|
+
- const canRead = accessApi.usePermission(permissions.read.id);
|
|
125
|
+
+ const canRead = accessApi.useAccess(access.read);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Routes
|
|
129
|
+
|
|
130
|
+
```diff
|
|
131
|
+
- permission: permissions.entityRead.id,
|
|
132
|
+
+ accessRule: access.read,
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Patch Changes
|
|
136
|
+
|
|
137
|
+
- Updated dependencies [9faec1f]
|
|
138
|
+
- Updated dependencies [95eeec7]
|
|
139
|
+
- Updated dependencies [f533141]
|
|
140
|
+
- @checkstack/auth-frontend@0.2.0
|
|
141
|
+
- @checkstack/catalog-common@1.1.0
|
|
142
|
+
- @checkstack/common@0.2.0
|
|
143
|
+
- @checkstack/frontend-api@0.1.0
|
|
144
|
+
- @checkstack/notification-common@0.1.0
|
|
145
|
+
- @checkstack/ui@0.2.0
|
|
146
|
+
|
|
3
147
|
## 0.1.0
|
|
4
148
|
|
|
5
149
|
### Minor Changes
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
import { createApiRef } from "@checkstack/frontend-api";
|
|
2
|
-
import { CatalogApi } from "@checkstack/catalog-common";
|
|
3
|
-
import type { InferClient } from "@checkstack/common";
|
|
4
|
-
|
|
5
1
|
// Re-export types for convenience
|
|
6
2
|
export type { System, Group, View } from "@checkstack/catalog-common";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export type CatalogApiClient = InferClient<typeof CatalogApi>;
|
|
10
|
-
|
|
11
|
-
export const catalogApiRef =
|
|
12
|
-
createApiRef<CatalogApiClient>("plugin.catalog.api");
|
|
3
|
+
// Client definition is in @checkstack/catalog-common - use with usePluginClient
|
|
4
|
+
export { CatalogApi } from "@checkstack/catalog-common";
|
|
@@ -2,11 +2,15 @@ import React, { useState, useEffect } from "react";
|
|
|
2
2
|
import { useSearchParams } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
4
|
useApi,
|
|
5
|
-
|
|
5
|
+
accessApiRef,
|
|
6
6
|
ExtensionSlot,
|
|
7
|
+
usePluginClient,
|
|
7
8
|
} from "@checkstack/frontend-api";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { System, CatalogApi } from "../api";
|
|
10
|
+
import {
|
|
11
|
+
CatalogSystemActionsSlot,
|
|
12
|
+
catalogAccess,
|
|
13
|
+
} from "@checkstack/catalog-common";
|
|
10
14
|
import {
|
|
11
15
|
SectionHeader,
|
|
12
16
|
Card,
|
|
@@ -17,7 +21,7 @@ import {
|
|
|
17
21
|
Label,
|
|
18
22
|
LoadingSpinner,
|
|
19
23
|
EmptyState,
|
|
20
|
-
|
|
24
|
+
AccessDenied,
|
|
21
25
|
EditableText,
|
|
22
26
|
ConfirmationModal,
|
|
23
27
|
useToast,
|
|
@@ -27,25 +31,22 @@ import { SystemEditor } from "./SystemEditor";
|
|
|
27
31
|
import { GroupEditor } from "./GroupEditor";
|
|
28
32
|
|
|
29
33
|
export const CatalogConfigPage = () => {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
34
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
35
|
+
const accessApi = useApi(accessApiRef);
|
|
32
36
|
const toast = useToast();
|
|
33
37
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
34
|
-
const { allowed: canManage, loading:
|
|
35
|
-
|
|
38
|
+
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
39
|
+
catalogAccess.system.manage
|
|
40
|
+
);
|
|
36
41
|
|
|
37
|
-
const [
|
|
38
|
-
const [
|
|
39
|
-
const [loading, setLoading] = useState(true);
|
|
42
|
+
const [selectedGroupId, setSelectedGroupId] = useState("");
|
|
43
|
+
const [selectedSystemToAdd, setSelectedSystemToAdd] = useState("");
|
|
40
44
|
|
|
41
45
|
// Dialog state
|
|
42
46
|
const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
|
|
43
47
|
const [editingSystem, setEditingSystem] = useState<System | undefined>();
|
|
44
48
|
const [isGroupEditorOpen, setIsGroupEditorOpen] = useState(false);
|
|
45
49
|
|
|
46
|
-
const [selectedGroupId, setSelectedGroupId] = useState("");
|
|
47
|
-
const [selectedSystemToAdd, setSelectedSystemToAdd] = useState("");
|
|
48
|
-
|
|
49
50
|
// Confirmation modal state
|
|
50
51
|
const [confirmModal, setConfirmModal] = useState<{
|
|
51
52
|
isOpen: boolean;
|
|
@@ -59,31 +60,30 @@ export const CatalogConfigPage = () => {
|
|
|
59
60
|
onConfirm: () => {},
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const message =
|
|
76
|
-
error instanceof Error ? error.message : "Failed to load catalog data";
|
|
77
|
-
toast.error(message);
|
|
78
|
-
console.error("Failed to load catalog data:", error);
|
|
79
|
-
} finally {
|
|
80
|
-
setLoading(false);
|
|
81
|
-
}
|
|
82
|
-
};
|
|
63
|
+
// Fetch systems with useQuery
|
|
64
|
+
const {
|
|
65
|
+
data: systemsData,
|
|
66
|
+
isLoading: systemsLoading,
|
|
67
|
+
refetch: refetchSystems,
|
|
68
|
+
} = catalogClient.getSystems.useQuery({});
|
|
69
|
+
|
|
70
|
+
// Fetch groups with useQuery
|
|
71
|
+
const {
|
|
72
|
+
data: groupsData,
|
|
73
|
+
isLoading: groupsLoading,
|
|
74
|
+
refetch: refetchGroups,
|
|
75
|
+
} = catalogClient.getGroups.useQuery({});
|
|
83
76
|
|
|
77
|
+
const systems = systemsData?.systems ?? [];
|
|
78
|
+
const groups = groupsData ?? [];
|
|
79
|
+
const loading = systemsLoading || groupsLoading;
|
|
80
|
+
|
|
81
|
+
// Set initial group selection
|
|
84
82
|
useEffect(() => {
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
if (groups.length > 0 && !selectedGroupId) {
|
|
84
|
+
setSelectedGroupId(groups[0].id);
|
|
85
|
+
}
|
|
86
|
+
}, [groups, selectedGroupId]);
|
|
87
87
|
|
|
88
88
|
// Handle ?action=create URL parameter (from command palette)
|
|
89
89
|
useEffect(() => {
|
|
@@ -95,134 +95,174 @@ export const CatalogConfigPage = () => {
|
|
|
95
95
|
}
|
|
96
96
|
}, [searchParams, canManage, setSearchParams]);
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Mutations
|
|
99
|
+
const createSystemMutation = catalogClient.createSystem.useMutation({
|
|
100
|
+
onSuccess: () => {
|
|
101
|
+
toast.success("System created successfully");
|
|
102
|
+
setIsSystemEditorOpen(false);
|
|
103
|
+
void refetchSystems();
|
|
104
|
+
},
|
|
105
|
+
onError: (error) => {
|
|
106
|
+
toast.error(
|
|
107
|
+
error instanceof Error ? error.message : "Failed to create system"
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const updateSystemMutation = catalogClient.updateSystem.useMutation({
|
|
113
|
+
onSuccess: () => {
|
|
114
|
+
toast.success("System updated successfully");
|
|
115
|
+
setIsSystemEditorOpen(false);
|
|
116
|
+
setEditingSystem(undefined);
|
|
117
|
+
void refetchSystems();
|
|
118
|
+
},
|
|
119
|
+
onError: (error) => {
|
|
120
|
+
toast.error(
|
|
121
|
+
error instanceof Error ? error.message : "Failed to update system"
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const deleteSystemMutation = catalogClient.deleteSystem.useMutation({
|
|
127
|
+
onSuccess: () => {
|
|
128
|
+
toast.success("System deleted successfully");
|
|
129
|
+
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
130
|
+
void refetchSystems();
|
|
131
|
+
},
|
|
132
|
+
onError: (error) => {
|
|
133
|
+
toast.error(
|
|
134
|
+
error instanceof Error ? error.message : "Failed to delete system"
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const createGroupMutation = catalogClient.createGroup.useMutation({
|
|
140
|
+
onSuccess: () => {
|
|
141
|
+
toast.success("Group created successfully");
|
|
142
|
+
setIsGroupEditorOpen(false);
|
|
143
|
+
void refetchGroups();
|
|
144
|
+
},
|
|
145
|
+
onError: (error) => {
|
|
146
|
+
toast.error(
|
|
147
|
+
error instanceof Error ? error.message : "Failed to create group"
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const deleteGroupMutation = catalogClient.deleteGroup.useMutation({
|
|
153
|
+
onSuccess: () => {
|
|
154
|
+
toast.success("Group deleted successfully");
|
|
155
|
+
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
156
|
+
void refetchGroups();
|
|
157
|
+
},
|
|
158
|
+
onError: (error) => {
|
|
159
|
+
toast.error(
|
|
160
|
+
error instanceof Error ? error.message : "Failed to delete group"
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const updateGroupMutation = catalogClient.updateGroup.useMutation({
|
|
166
|
+
onSuccess: () => {
|
|
167
|
+
toast.success("Group name updated successfully");
|
|
168
|
+
void refetchGroups();
|
|
169
|
+
},
|
|
170
|
+
onError: (error) => {
|
|
171
|
+
toast.error(
|
|
172
|
+
error instanceof Error ? error.message : "Failed to update group name"
|
|
173
|
+
);
|
|
174
|
+
throw error;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const addSystemToGroupMutation = catalogClient.addSystemToGroup.useMutation({
|
|
179
|
+
onSuccess: () => {
|
|
180
|
+
toast.success("System added to group successfully");
|
|
181
|
+
setSelectedSystemToAdd("");
|
|
182
|
+
void refetchGroups();
|
|
183
|
+
},
|
|
184
|
+
onError: (error) => {
|
|
185
|
+
toast.error(
|
|
186
|
+
error instanceof Error ? error.message : "Failed to add system to group"
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const removeSystemFromGroupMutation =
|
|
192
|
+
catalogClient.removeSystemFromGroup.useMutation({
|
|
193
|
+
onSuccess: () => {
|
|
194
|
+
toast.success("System removed from group successfully");
|
|
195
|
+
void refetchGroups();
|
|
196
|
+
},
|
|
197
|
+
onError: (error) => {
|
|
198
|
+
toast.error(
|
|
199
|
+
error instanceof Error
|
|
200
|
+
? error.message
|
|
201
|
+
: "Failed to remove system from group"
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Handlers
|
|
99
207
|
const handleSaveSystem = async (data: {
|
|
100
208
|
name: string;
|
|
101
209
|
description?: string;
|
|
102
210
|
}) => {
|
|
103
211
|
if (editingSystem) {
|
|
104
|
-
|
|
105
|
-
await catalogApi.updateSystem({
|
|
106
|
-
id: editingSystem.id,
|
|
107
|
-
data,
|
|
108
|
-
});
|
|
109
|
-
toast.success("System updated successfully");
|
|
110
|
-
setEditingSystem(undefined);
|
|
212
|
+
updateSystemMutation.mutate({ id: editingSystem.id, data });
|
|
111
213
|
} else {
|
|
112
|
-
|
|
113
|
-
await catalogApi.createSystem(data);
|
|
114
|
-
toast.success("System created successfully");
|
|
214
|
+
createSystemMutation.mutate(data);
|
|
115
215
|
}
|
|
116
|
-
setIsSystemEditorOpen(false);
|
|
117
|
-
await loadData();
|
|
118
216
|
};
|
|
119
217
|
|
|
120
218
|
const handleCreateGroup = async (data: { name: string }) => {
|
|
121
|
-
|
|
122
|
-
toast.success("Group created successfully");
|
|
123
|
-
await loadData();
|
|
219
|
+
createGroupMutation.mutate(data);
|
|
124
220
|
};
|
|
125
221
|
|
|
126
|
-
const handleDeleteSystem =
|
|
222
|
+
const handleDeleteSystem = (id: string) => {
|
|
127
223
|
const system = systems.find((s) => s.id === id);
|
|
128
224
|
setConfirmModal({
|
|
129
225
|
isOpen: true,
|
|
130
226
|
title: "Delete System",
|
|
131
227
|
message: `Are you sure you want to delete "${system?.name}"? This will remove the system from all groups as well.`,
|
|
132
|
-
onConfirm:
|
|
133
|
-
|
|
134
|
-
await catalogApi.deleteSystem(id);
|
|
135
|
-
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
136
|
-
toast.success("System deleted successfully");
|
|
137
|
-
loadData();
|
|
138
|
-
} catch (error) {
|
|
139
|
-
const message =
|
|
140
|
-
error instanceof Error ? error.message : "Failed to delete system";
|
|
141
|
-
toast.error(message);
|
|
142
|
-
console.error("Failed to delete system:", error);
|
|
143
|
-
}
|
|
228
|
+
onConfirm: () => {
|
|
229
|
+
deleteSystemMutation.mutate(id);
|
|
144
230
|
},
|
|
145
231
|
});
|
|
146
232
|
};
|
|
147
233
|
|
|
148
|
-
const handleDeleteGroup =
|
|
234
|
+
const handleDeleteGroup = (id: string) => {
|
|
149
235
|
const group = groups.find((g) => g.id === id);
|
|
150
236
|
setConfirmModal({
|
|
151
237
|
isOpen: true,
|
|
152
238
|
title: "Delete Group",
|
|
153
239
|
message: `Are you sure you want to delete "${group?.name}"? This action cannot be undone.`,
|
|
154
|
-
onConfirm:
|
|
155
|
-
|
|
156
|
-
await catalogApi.deleteGroup(id);
|
|
157
|
-
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
158
|
-
toast.success("Group deleted successfully");
|
|
159
|
-
loadData();
|
|
160
|
-
} catch (error) {
|
|
161
|
-
const message =
|
|
162
|
-
error instanceof Error ? error.message : "Failed to delete group";
|
|
163
|
-
toast.error(message);
|
|
164
|
-
console.error("Failed to delete group:", error);
|
|
165
|
-
}
|
|
240
|
+
onConfirm: () => {
|
|
241
|
+
deleteGroupMutation.mutate(id);
|
|
166
242
|
},
|
|
167
243
|
});
|
|
168
244
|
};
|
|
169
245
|
|
|
170
|
-
const handleAddSystemToGroup =
|
|
246
|
+
const handleAddSystemToGroup = () => {
|
|
171
247
|
if (!selectedGroupId || !selectedSystemToAdd) return;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
setSelectedSystemToAdd("");
|
|
178
|
-
toast.success("System added to group successfully");
|
|
179
|
-
loadData();
|
|
180
|
-
} catch (error) {
|
|
181
|
-
const message =
|
|
182
|
-
error instanceof Error
|
|
183
|
-
? error.message
|
|
184
|
-
: "Failed to add system to group";
|
|
185
|
-
toast.error(message);
|
|
186
|
-
console.error("Failed to add system to group:", error);
|
|
187
|
-
}
|
|
248
|
+
addSystemToGroupMutation.mutate({
|
|
249
|
+
groupId: selectedGroupId,
|
|
250
|
+
systemId: selectedSystemToAdd,
|
|
251
|
+
});
|
|
188
252
|
};
|
|
189
253
|
|
|
190
|
-
const handleRemoveSystemFromGroup =
|
|
191
|
-
groupId
|
|
192
|
-
systemId: string
|
|
193
|
-
) => {
|
|
194
|
-
try {
|
|
195
|
-
await catalogApi.removeSystemFromGroup({ groupId, systemId });
|
|
196
|
-
toast.success("System removed from group successfully");
|
|
197
|
-
loadData();
|
|
198
|
-
} catch (error) {
|
|
199
|
-
const message =
|
|
200
|
-
error instanceof Error
|
|
201
|
-
? error.message
|
|
202
|
-
: "Failed to remove system from group";
|
|
203
|
-
toast.error(message);
|
|
204
|
-
console.error("Failed to remove system from group:", error);
|
|
205
|
-
}
|
|
254
|
+
const handleRemoveSystemFromGroup = (groupId: string, systemId: string) => {
|
|
255
|
+
removeSystemFromGroupMutation.mutate({ groupId, systemId });
|
|
206
256
|
};
|
|
207
257
|
|
|
208
|
-
const handleUpdateGroupName =
|
|
209
|
-
|
|
210
|
-
await catalogApi.updateGroup({ id, data: { name: newName } });
|
|
211
|
-
toast.success("Group name updated successfully");
|
|
212
|
-
loadData();
|
|
213
|
-
} catch (error) {
|
|
214
|
-
const message =
|
|
215
|
-
error instanceof Error ? error.message : "Failed to update group name";
|
|
216
|
-
toast.error(message);
|
|
217
|
-
console.error("Failed to update group name:", error);
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
258
|
+
const handleUpdateGroupName = (id: string, newName: string) => {
|
|
259
|
+
updateGroupMutation.mutate({ id, data: { name: newName } });
|
|
220
260
|
};
|
|
221
261
|
|
|
222
|
-
if (loading ||
|
|
262
|
+
if (loading || accessLoading) return <LoadingSpinner />;
|
|
223
263
|
|
|
224
264
|
if (!canManage) {
|
|
225
|
-
return <
|
|
265
|
+
return <AccessDenied />;
|
|
226
266
|
}
|
|
227
267
|
|
|
228
268
|
const selectedGroup = groups.find((g) => g.id === selectedGroupId);
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { useApi, loggerApiRef } from "@checkstack/frontend-api";
|
|
3
|
-
import { catalogApiRef } from "../api";
|
|
4
3
|
|
|
5
4
|
export const CatalogPage = () => {
|
|
6
5
|
const logger = useApi(loggerApiRef);
|
|
7
|
-
const catalog = useApi(catalogApiRef);
|
|
8
6
|
|
|
9
7
|
React.useEffect(() => {
|
|
10
|
-
logger.info("Catalog Page loaded"
|
|
11
|
-
}, [logger
|
|
8
|
+
logger.info("Catalog Page loaded");
|
|
9
|
+
}, [logger]);
|
|
12
10
|
|
|
13
11
|
return (
|
|
14
12
|
<div className="p-4 rounded-lg bg-white shadow">
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import React, { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { useParams, useNavigate } from "react-router-dom";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
ExtensionSlot,
|
|
6
|
+
useApi,
|
|
7
|
+
} from "@checkstack/frontend-api";
|
|
8
|
+
import { Group, CatalogApi } from "../api";
|
|
6
9
|
import {
|
|
7
10
|
SystemDetailsSlot,
|
|
8
11
|
SystemDetailsTopSlot,
|
|
@@ -28,16 +31,13 @@ const CATALOG_PLUGIN_ID = "catalog";
|
|
|
28
31
|
export const SystemDetailPage: React.FC = () => {
|
|
29
32
|
const { systemId } = useParams<{ systemId: string }>();
|
|
30
33
|
const navigate = useNavigate();
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const notificationApi = rpcApi.forPlugin(NotificationApi);
|
|
34
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
35
|
+
const notificationClient = usePluginClient(NotificationApi);
|
|
34
36
|
const toast = useToast();
|
|
35
37
|
const authApi = useApi(authApiRef);
|
|
36
38
|
const { data: session } = authApi.useSession();
|
|
37
39
|
|
|
38
|
-
const [system, setSystem] = useState<System | undefined>();
|
|
39
40
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
40
|
-
const [loading, setLoading] = useState(true);
|
|
41
41
|
const [notFound, setNotFound] = useState(false);
|
|
42
42
|
|
|
43
43
|
// Subscription state
|
|
@@ -49,85 +49,84 @@ export const SystemDetailPage: React.FC = () => {
|
|
|
49
49
|
return `${CATALOG_PLUGIN_ID}.system.${systemId}`;
|
|
50
50
|
}, [systemId]);
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
// Fetch system data with useQuery
|
|
53
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
54
|
+
catalogClient.getSystems.useQuery({});
|
|
55
|
+
|
|
56
|
+
// Fetch groups data with useQuery
|
|
57
|
+
const { data: groupsData, isLoading: groupsLoading } =
|
|
58
|
+
catalogClient.getGroups.useQuery({});
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Find the system from the fetched data
|
|
61
|
+
const system = systemsData?.systems.find((s) => s.id === systemId);
|
|
62
|
+
const loading = systemsLoading || groupsLoading;
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
64
|
+
// Fetch subscriptions with useQuery
|
|
65
|
+
const { data: subscriptions, refetch: refetchSubscriptions } =
|
|
66
|
+
notificationClient.getSubscriptions.useQuery({});
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// Subscribe/unsubscribe mutations
|
|
69
|
+
const subscribeMutation = notificationClient.subscribe.useMutation({
|
|
70
|
+
onSuccess: () => {
|
|
71
|
+
setIsSubscribed(true);
|
|
72
|
+
toast.success("Subscribed to system notifications");
|
|
73
|
+
void refetchSubscriptions();
|
|
74
|
+
},
|
|
75
|
+
onError: (error) => {
|
|
76
|
+
toast.error(
|
|
77
|
+
error instanceof Error ? error.message : "Failed to subscribe"
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
+
const unsubscribeMutation = notificationClient.unsubscribe.useMutation({
|
|
83
|
+
onSuccess: () => {
|
|
84
|
+
setIsSubscribed(false);
|
|
85
|
+
toast.success("Unsubscribed from system notifications");
|
|
86
|
+
void refetchSubscriptions();
|
|
87
|
+
},
|
|
88
|
+
onError: (error) => {
|
|
89
|
+
toast.error(
|
|
90
|
+
error instanceof Error ? error.message : "Failed to unsubscribe"
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
82
94
|
|
|
83
|
-
//
|
|
95
|
+
// Update not found state
|
|
84
96
|
useEffect(() => {
|
|
85
|
-
if (!systemId)
|
|
97
|
+
if (!systemsLoading && !system && systemId) {
|
|
98
|
+
setNotFound(true);
|
|
99
|
+
}
|
|
100
|
+
}, [system, systemsLoading, systemId]);
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
})
|
|
97
|
-
.catch((error) => {
|
|
98
|
-
console.error("Failed to check subscription status:", error);
|
|
99
|
-
})
|
|
100
|
-
.finally(() => setSubscriptionLoading(false));
|
|
101
|
-
}, [systemId, notificationApi, getSystemGroupId]);
|
|
102
|
+
// Update groups that contain this system
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (groupsData && systemId) {
|
|
105
|
+
const systemGroups = groupsData.filter((group) =>
|
|
106
|
+
group.systemIds?.includes(systemId)
|
|
107
|
+
);
|
|
108
|
+
setGroups(systemGroups);
|
|
109
|
+
}
|
|
110
|
+
}, [groupsData, systemId]);
|
|
102
111
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} catch (error) {
|
|
110
|
-
const message =
|
|
111
|
-
error instanceof Error ? error.message : "Failed to subscribe";
|
|
112
|
-
toast.error(message);
|
|
113
|
-
} finally {
|
|
112
|
+
// Update subscription status from query
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (subscriptions && systemId) {
|
|
115
|
+
const groupId = getSystemGroupId();
|
|
116
|
+
const hasSubscription = subscriptions.some((s) => s.groupId === groupId);
|
|
117
|
+
setIsSubscribed(hasSubscription);
|
|
114
118
|
setSubscriptionLoading(false);
|
|
115
119
|
}
|
|
120
|
+
}, [subscriptions, systemId, getSystemGroupId]);
|
|
121
|
+
|
|
122
|
+
const handleSubscribe = () => {
|
|
123
|
+
setSubscriptionLoading(true);
|
|
124
|
+
subscribeMutation.mutate({ groupId: getSystemGroupId() });
|
|
116
125
|
};
|
|
117
126
|
|
|
118
|
-
const handleUnsubscribe =
|
|
127
|
+
const handleUnsubscribe = () => {
|
|
119
128
|
setSubscriptionLoading(true);
|
|
120
|
-
|
|
121
|
-
await notificationApi.unsubscribe({ groupId: getSystemGroupId() });
|
|
122
|
-
setIsSubscribed(false);
|
|
123
|
-
toast.success("Unsubscribed from system notifications");
|
|
124
|
-
} catch (error) {
|
|
125
|
-
const message =
|
|
126
|
-
error instanceof Error ? error.message : "Failed to unsubscribe";
|
|
127
|
-
toast.error(message);
|
|
128
|
-
} finally {
|
|
129
|
-
setSubscriptionLoading(false);
|
|
130
|
-
}
|
|
129
|
+
unsubscribeMutation.mutate({ groupId: getSystemGroupId() });
|
|
131
130
|
};
|
|
132
131
|
|
|
133
132
|
if (loading) {
|
|
@@ -175,7 +174,11 @@ export const SystemDetailPage: React.FC = () => {
|
|
|
175
174
|
isSubscribed={isSubscribed}
|
|
176
175
|
onSubscribe={handleSubscribe}
|
|
177
176
|
onUnsubscribe={handleUnsubscribe}
|
|
178
|
-
loading={
|
|
177
|
+
loading={
|
|
178
|
+
subscriptionLoading ||
|
|
179
|
+
subscribeMutation.isPending ||
|
|
180
|
+
unsubscribeMutation.isPending
|
|
181
|
+
}
|
|
179
182
|
/>
|
|
180
183
|
)}
|
|
181
184
|
<BackLink onClick={() => navigate("/")}>Back to Dashboard</BackLink>
|
|
@@ -3,20 +3,18 @@ import { Link } from "react-router-dom";
|
|
|
3
3
|
import { Settings } from "lucide-react";
|
|
4
4
|
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
5
|
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
7
|
import {
|
|
8
8
|
catalogRoutes,
|
|
9
|
-
|
|
9
|
+
catalogAccess,
|
|
10
10
|
pluginMetadata,
|
|
11
11
|
} from "@checkstack/catalog-common";
|
|
12
12
|
|
|
13
13
|
export const CatalogUserMenuItems = ({
|
|
14
|
-
|
|
14
|
+
accessRules: userPerms,
|
|
15
15
|
}: UserMenuItemsContext) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
permissions.catalogManage
|
|
19
|
-
);
|
|
16
|
+
// Use the access rule's id directly
|
|
17
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${catalogAccess.system.manage.id}`;
|
|
20
18
|
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
21
19
|
|
|
22
20
|
if (!canManage) {
|
package/src/index.tsx
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
rpcApiRef,
|
|
3
|
-
ApiRef,
|
|
4
2
|
UserMenuItemsSlot,
|
|
5
3
|
createSlotExtension,
|
|
6
4
|
createFrontendPlugin,
|
|
7
5
|
} from "@checkstack/frontend-api";
|
|
8
|
-
import { catalogApiRef, type CatalogApiClient } from "./api";
|
|
9
6
|
import {
|
|
10
7
|
catalogRoutes,
|
|
11
|
-
CatalogApi,
|
|
12
8
|
pluginMetadata,
|
|
13
|
-
|
|
9
|
+
catalogAccess,
|
|
14
10
|
} from "@checkstack/catalog-common";
|
|
15
11
|
|
|
16
12
|
import { CatalogPage } from "./components/CatalogPage";
|
|
@@ -20,16 +16,8 @@ import { SystemDetailPage } from "./components/SystemDetailPage";
|
|
|
20
16
|
|
|
21
17
|
export const catalogPlugin = createFrontendPlugin({
|
|
22
18
|
metadata: pluginMetadata,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
ref: catalogApiRef,
|
|
26
|
-
factory: (deps: { get: <T>(ref: ApiRef<T>) => T }): CatalogApiClient => {
|
|
27
|
-
const rpcApi = deps.get(rpcApiRef);
|
|
28
|
-
// CatalogApiClient is derived from the contract type
|
|
29
|
-
return rpcApi.forPlugin(CatalogApi);
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
],
|
|
19
|
+
// No APIs needed - components use usePluginClient() directly
|
|
20
|
+
apis: [],
|
|
33
21
|
routes: [
|
|
34
22
|
{
|
|
35
23
|
route: catalogRoutes.routes.home,
|
|
@@ -38,7 +26,7 @@ export const catalogPlugin = createFrontendPlugin({
|
|
|
38
26
|
{
|
|
39
27
|
route: catalogRoutes.routes.config,
|
|
40
28
|
element: <CatalogConfigPage />,
|
|
41
|
-
|
|
29
|
+
accessRule: catalogAccess.system.manage,
|
|
42
30
|
},
|
|
43
31
|
{
|
|
44
32
|
route: catalogRoutes.routes.systemDetail,
|