@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
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
- // CatalogApi client type inferred from the client definition
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
- permissionApiRef,
5
+ accessApiRef,
6
6
  ExtensionSlot,
7
+ usePluginClient,
7
8
  } from "@checkstack/frontend-api";
8
- import { catalogApiRef, System, Group } from "../api";
9
- import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
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
- PermissionDenied,
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 catalogApi = useApi(catalogApiRef);
31
- const permissionApi = useApi(permissionApiRef);
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: permissionLoading } =
35
- permissionApi.useManagePermission("catalog");
38
+ const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
39
+ catalogAccess.system.manage
40
+ );
36
41
 
37
- const [systems, setSystems] = useState<System[]>([]);
38
- const [groups, setGroups] = useState<Group[]>([]);
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
- const loadData = async () => {
63
- setLoading(true);
64
- try {
65
- const [{ systems: s }, g] = await Promise.all([
66
- catalogApi.getSystems(),
67
- catalogApi.getGroups(),
68
- ]);
69
- setSystems(s);
70
- setGroups(g);
71
- if (g.length > 0 && !selectedGroupId) {
72
- setSelectedGroupId(g[0].id);
73
- }
74
- } catch (error) {
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
- loadData();
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
- // Unified save handler for both create and edit
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
- // Update existing system
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
- // Create new system
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
- await catalogApi.createGroup(data);
122
- toast.success("Group created successfully");
123
- await loadData();
219
+ createGroupMutation.mutate(data);
124
220
  };
125
221
 
126
- const handleDeleteSystem = async (id: string) => {
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: async () => {
133
- try {
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 = async (id: string) => {
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: async () => {
155
- try {
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 = async () => {
246
+ const handleAddSystemToGroup = () => {
171
247
  if (!selectedGroupId || !selectedSystemToAdd) return;
172
- try {
173
- await catalogApi.addSystemToGroup({
174
- groupId: selectedGroupId,
175
- systemId: selectedSystemToAdd,
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 = async (
191
- groupId: string,
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 = async (id: string, newName: string) => {
209
- try {
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 || permissionLoading) return <LoadingSpinner />;
262
+ if (loading || accessLoading) return <LoadingSpinner />;
223
263
 
224
264
  if (!canManage) {
225
- return <PermissionDenied />;
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", catalog);
11
- }, [logger, catalog]);
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 { useApi, rpcApiRef } from "@checkstack/frontend-api";
4
- import { catalogApiRef, System, Group } from "../api";
5
- import { ExtensionSlot } from "@checkstack/frontend-api";
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 catalogApi = useApi(catalogApiRef);
32
- const rpcApi = useApi(rpcApiRef);
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
- useEffect(() => {
53
- if (!systemId) {
54
- setNotFound(true);
55
- setLoading(false);
56
- return;
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
- Promise.all([catalogApi.getSystems(), catalogApi.getGroups()])
60
- .then(([{ systems }, allGroups]) => {
61
- const foundSystem = systems.find((s) => s.id === systemId);
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
- if (!foundSystem) {
64
- setNotFound(true);
65
- return;
66
- }
64
+ // Fetch subscriptions with useQuery
65
+ const { data: subscriptions, refetch: refetchSubscriptions } =
66
+ notificationClient.getSubscriptions.useQuery({});
67
67
 
68
- setSystem(foundSystem);
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
- // Find groups that contain this system
71
- const systemGroups = allGroups.filter((group) =>
72
- group.systemIds?.includes(systemId)
73
- );
74
- setGroups(systemGroups);
75
- })
76
- .catch((error) => {
77
- console.error("Error fetching system details:", error);
78
- setNotFound(true);
79
- })
80
- .finally(() => setLoading(false));
81
- }, [systemId, catalogApi]);
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
- // Check subscription status
95
+ // Update not found state
84
96
  useEffect(() => {
85
- if (!systemId) return;
97
+ if (!systemsLoading && !system && systemId) {
98
+ setNotFound(true);
99
+ }
100
+ }, [system, systemsLoading, systemId]);
86
101
 
87
- setSubscriptionLoading(true);
88
- notificationApi
89
- .getSubscriptions()
90
- .then((subscriptions) => {
91
- const groupId = getSystemGroupId();
92
- const hasSubscription = subscriptions.some(
93
- (s) => s.groupId === groupId
94
- );
95
- setIsSubscribed(hasSubscription);
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
- const handleSubscribe = async () => {
104
- setSubscriptionLoading(true);
105
- try {
106
- await notificationApi.subscribe({ groupId: getSystemGroupId() });
107
- setIsSubscribed(true);
108
- toast.success("Subscribed to system notifications");
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 = async () => {
127
+ const handleUnsubscribe = () => {
119
128
  setSubscriptionLoading(true);
120
- try {
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={subscriptionLoading}
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 { qualifyPermissionId, resolveRoute } from "@checkstack/common";
6
+ import { resolveRoute } from "@checkstack/common";
7
7
  import {
8
8
  catalogRoutes,
9
- permissions,
9
+ catalogAccess,
10
10
  pluginMetadata,
11
11
  } from "@checkstack/catalog-common";
12
12
 
13
13
  export const CatalogUserMenuItems = ({
14
- permissions: userPerms,
14
+ accessRules: userPerms,
15
15
  }: UserMenuItemsContext) => {
16
- const qualifiedId = qualifyPermissionId(
17
- pluginMetadata,
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
- permissions,
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
- apis: [
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
- permission: permissions.catalogManage,
29
+ accessRule: catalogAccess.system.manage,
42
30
  },
43
31
  {
44
32
  route: catalogRoutes.routes.systemDetail,