@checkstack/auth-frontend 0.0.3 → 0.1.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.
@@ -0,0 +1,560 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardContent,
7
+ Button,
8
+ Badge,
9
+ useToast,
10
+ LoadingSpinner,
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ Checkbox,
17
+ Toggle,
18
+ Label,
19
+ } from "@checkstack/ui";
20
+ import {
21
+ Plus,
22
+ Trash2,
23
+ Users2,
24
+ Shield,
25
+ Eye,
26
+ Settings,
27
+ Lock,
28
+ } from "lucide-react";
29
+ import { useApi, rpcApiRef, permissionApiRef } from "@checkstack/frontend-api";
30
+ import {
31
+ AuthApi,
32
+ permissions as authPermissions,
33
+ } from "@checkstack/auth-common";
34
+
35
+ interface TeamAccess {
36
+ teamId: string;
37
+ teamName: string;
38
+ canRead: boolean;
39
+ canManage: boolean;
40
+ }
41
+
42
+ interface Team {
43
+ id: string;
44
+ name: string;
45
+ description?: string | null;
46
+ memberCount: number;
47
+ isManager: boolean;
48
+ }
49
+
50
+ export interface TeamAccessEditorProps {
51
+ /** Resource type identifier (e.g., "catalog.system", "healthcheck.configuration") */
52
+ resourceType: string;
53
+ /** Resource ID */
54
+ resourceId: string;
55
+ /** Whether the editor is expanded/visible */
56
+ expanded?: boolean;
57
+ /** Compact mode for inline display */
58
+ compact?: boolean;
59
+ /** Called when access is modified */
60
+ onChange?: () => void;
61
+ }
62
+
63
+ /**
64
+ * Reusable component for managing team-based access to resources.
65
+ * Used in System editor, Health Check editor, Incident/Maintenance forms.
66
+ */
67
+ export const TeamAccessEditor: React.FC<TeamAccessEditorProps> = ({
68
+ resourceType,
69
+ resourceId,
70
+ expanded: initialExpanded = false,
71
+ compact = false,
72
+ onChange,
73
+ }) => {
74
+ const rpcApi = useApi(rpcApiRef);
75
+ const permissionApi = useApi(permissionApiRef);
76
+ const authClient = rpcApi.forPlugin(AuthApi);
77
+ const toast = useToast();
78
+
79
+ const { allowed: canManageTeams } = permissionApi.usePermission(
80
+ authPermissions.teamsManage.id
81
+ );
82
+
83
+ const [expanded, setExpanded] = useState(initialExpanded);
84
+ const [loading, setLoading] = useState(false);
85
+ const [accessList, setAccessList] = useState<TeamAccess[]>([]);
86
+ const [teams, setTeams] = useState<Team[]>([]);
87
+ const [selectedTeamId, setSelectedTeamId] = useState("");
88
+ const [adding, setAdding] = useState(false);
89
+ const [teamOnly, setTeamOnly] = useState(false);
90
+
91
+ const loadData = useCallback(async () => {
92
+ setLoading(true);
93
+ try {
94
+ const [accessData, teamsData, settingsData] = await Promise.all([
95
+ authClient.getResourceTeamAccess({ resourceType, resourceId }),
96
+ authClient.getTeams(),
97
+ authClient.getResourceAccessSettings({ resourceType, resourceId }),
98
+ ]);
99
+ setAccessList(accessData);
100
+ setTeams(teamsData);
101
+ setTeamOnly(settingsData.teamOnly);
102
+ } catch (error) {
103
+ toast.error(
104
+ error instanceof Error ? error.message : "Failed to load team access"
105
+ );
106
+ } finally {
107
+ setLoading(false);
108
+ }
109
+ }, [authClient, resourceType, resourceId, toast]);
110
+
111
+ useEffect(() => {
112
+ if (expanded && resourceId) {
113
+ loadData();
114
+ }
115
+ }, [expanded, resourceId, loadData]);
116
+
117
+ const handleAddTeam = async () => {
118
+ if (!selectedTeamId) return;
119
+
120
+ setAdding(true);
121
+ try {
122
+ await authClient.setResourceTeamAccess({
123
+ resourceType,
124
+ resourceId,
125
+ teamId: selectedTeamId,
126
+ canRead: true,
127
+ canManage: false,
128
+ });
129
+ toast.success("Team access granted");
130
+ setSelectedTeamId("");
131
+ await loadData();
132
+ onChange?.();
133
+ } catch (error) {
134
+ toast.error(
135
+ error instanceof Error ? error.message : "Failed to add team access"
136
+ );
137
+ } finally {
138
+ setAdding(false);
139
+ }
140
+ };
141
+
142
+ const handleUpdateAccess = async (
143
+ teamId: string,
144
+ updates: { canRead?: boolean; canManage?: boolean }
145
+ ) => {
146
+ try {
147
+ await authClient.setResourceTeamAccess({
148
+ resourceType,
149
+ resourceId,
150
+ teamId,
151
+ ...updates,
152
+ });
153
+ await loadData();
154
+ onChange?.();
155
+ } catch (error) {
156
+ toast.error(
157
+ error instanceof Error ? error.message : "Failed to update access"
158
+ );
159
+ }
160
+ };
161
+
162
+ const handleUpdateSettings = async (newTeamOnly: boolean) => {
163
+ try {
164
+ await authClient.setResourceAccessSettings({
165
+ resourceType,
166
+ resourceId,
167
+ teamOnly: newTeamOnly,
168
+ });
169
+ setTeamOnly(newTeamOnly);
170
+ onChange?.();
171
+ } catch (error) {
172
+ toast.error(
173
+ error instanceof Error ? error.message : "Failed to update settings"
174
+ );
175
+ }
176
+ };
177
+
178
+ const handleRemoveAccess = async (teamId: string) => {
179
+ try {
180
+ await authClient.removeResourceTeamAccess({
181
+ resourceType,
182
+ resourceId,
183
+ teamId,
184
+ });
185
+
186
+ // Check if this was the last team - if so, clear settings too
187
+ const remainingTeams = accessList.filter((a) => a.teamId !== teamId);
188
+ if (remainingTeams.length === 0 && teamOnly) {
189
+ // Reset teamOnly when no teams have access
190
+ await authClient.setResourceAccessSettings({
191
+ resourceType,
192
+ resourceId,
193
+ teamOnly: false,
194
+ });
195
+ setTeamOnly(false);
196
+ }
197
+
198
+ toast.success("Team access removed");
199
+ await loadData();
200
+ onChange?.();
201
+ } catch (error) {
202
+ toast.error(
203
+ error instanceof Error ? error.message : "Failed to remove access"
204
+ );
205
+ }
206
+ };
207
+
208
+ // Get teams that don't already have access
209
+ const availableTeams = teams.filter(
210
+ (t) => !accessList.some((a) => a.teamId === t.id)
211
+ );
212
+
213
+ // Compact summary mode
214
+ if (!expanded) {
215
+ return (
216
+ <div className="flex items-center gap-2">
217
+ <Button
218
+ type="button"
219
+ variant="outline"
220
+ size="sm"
221
+ onClick={() => setExpanded(true)}
222
+ className="gap-1.5"
223
+ >
224
+ <Users2 className="h-4 w-4" />
225
+ <span>Team Access</span>
226
+ {accessList.length > 0 && (
227
+ <Badge variant="secondary" className="ml-1">
228
+ {accessList.length}
229
+ </Badge>
230
+ )}
231
+ </Button>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ // Full editor mode
237
+ if (compact) {
238
+ return (
239
+ <div className="border rounded-lg p-4 space-y-4">
240
+ <div className="flex items-center justify-between">
241
+ <div className="flex items-center gap-2">
242
+ <Shield className="h-4 w-4 text-muted-foreground" />
243
+ <span className="font-medium text-sm">Team Access Control</span>
244
+ </div>
245
+ <Button
246
+ type="button"
247
+ variant="ghost"
248
+ size="sm"
249
+ onClick={() => setExpanded(false)}
250
+ >
251
+ Collapse
252
+ </Button>
253
+ </div>
254
+
255
+ {loading ? (
256
+ <div className="flex justify-center py-4">
257
+ <LoadingSpinner />
258
+ </div>
259
+ ) : (
260
+ <>
261
+ {/* Resource-level Team Only setting */}
262
+ {accessList.length > 0 && (
263
+ <div className="flex items-center justify-between p-2 bg-muted/30 rounded-md">
264
+ <div className="flex items-center gap-2">
265
+ <Lock className="h-4 w-4 text-muted-foreground" />
266
+ <Label
267
+ htmlFor="team-only-compact"
268
+ className="text-sm cursor-pointer"
269
+ >
270
+ Team Only
271
+ </Label>
272
+ <span className="text-xs text-muted-foreground">
273
+ (Bypass global permissions)
274
+ </span>
275
+ </div>
276
+ <Toggle
277
+ checked={teamOnly}
278
+ onCheckedChange={handleUpdateSettings}
279
+ disabled={!canManageTeams}
280
+ />
281
+ </div>
282
+ )}
283
+
284
+ {/* Add team row */}
285
+ {canManageTeams && (
286
+ <div className="flex gap-2">
287
+ <Select
288
+ value={selectedTeamId}
289
+ onValueChange={setSelectedTeamId}
290
+ >
291
+ <SelectTrigger className="flex-1">
292
+ <SelectValue placeholder="Select team to add" />
293
+ </SelectTrigger>
294
+ <SelectContent>
295
+ {availableTeams.length === 0 ? (
296
+ <SelectItem value="_none" disabled>
297
+ No teams available
298
+ </SelectItem>
299
+ ) : (
300
+ availableTeams.map((team) => (
301
+ <SelectItem key={team.id} value={team.id}>
302
+ {team.name}
303
+ </SelectItem>
304
+ ))
305
+ )}
306
+ </SelectContent>
307
+ </Select>
308
+ <Button
309
+ type="button"
310
+ onClick={handleAddTeam}
311
+ disabled={!selectedTeamId || adding}
312
+ size="sm"
313
+ >
314
+ <Plus className="h-4 w-4" />
315
+ </Button>
316
+ </div>
317
+ )}
318
+
319
+ {/* Access list */}
320
+ <div className="space-y-2">
321
+ {accessList.length === 0 ? (
322
+ <p className="text-sm text-muted-foreground text-center py-2">
323
+ No team restrictions. All users with permission can access.
324
+ </p>
325
+ ) : (
326
+ accessList.map((access) => (
327
+ <div
328
+ key={access.teamId}
329
+ className="flex items-center justify-between p-2 bg-muted/50 rounded-md"
330
+ >
331
+ <div className="flex items-center gap-2">
332
+ <Users2 className="h-4 w-4 text-muted-foreground" />
333
+ <span className="font-medium text-sm">
334
+ {access.teamName}
335
+ </span>
336
+ <div className="flex gap-1">
337
+ {canManageTeams ? (
338
+ <>
339
+ <Badge
340
+ variant={access.canRead ? "default" : "secondary"}
341
+ className="text-xs cursor-pointer hover:opacity-80"
342
+ onClick={() =>
343
+ handleUpdateAccess(access.teamId, {
344
+ canRead: !access.canRead,
345
+ })
346
+ }
347
+ >
348
+ <Eye className="h-3 w-3 mr-1" />
349
+ Read
350
+ </Badge>
351
+ <Badge
352
+ variant={
353
+ access.canManage ? "default" : "secondary"
354
+ }
355
+ className="text-xs cursor-pointer hover:opacity-80"
356
+ onClick={() =>
357
+ handleUpdateAccess(access.teamId, {
358
+ canManage: !access.canManage,
359
+ })
360
+ }
361
+ >
362
+ <Settings className="h-3 w-3 mr-1" />
363
+ Manage
364
+ </Badge>
365
+ </>
366
+ ) : (
367
+ <>
368
+ {access.canRead && (
369
+ <Badge variant="outline" className="text-xs">
370
+ <Eye className="h-3 w-3 mr-1" />
371
+ Read
372
+ </Badge>
373
+ )}
374
+ {access.canManage && (
375
+ <Badge variant="secondary" className="text-xs">
376
+ <Settings className="h-3 w-3 mr-1" />
377
+ Manage
378
+ </Badge>
379
+ )}
380
+ </>
381
+ )}
382
+ </div>
383
+ </div>
384
+ {canManageTeams && (
385
+ <Button
386
+ type="button"
387
+ variant="ghost"
388
+ size="sm"
389
+ onClick={() => handleRemoveAccess(access.teamId)}
390
+ >
391
+ <Trash2 className="h-4 w-4" />
392
+ </Button>
393
+ )}
394
+ </div>
395
+ ))
396
+ )}
397
+ </div>
398
+ </>
399
+ )}
400
+ </div>
401
+ );
402
+ }
403
+
404
+ // Card mode (default)
405
+ return (
406
+ <Card>
407
+ <CardHeader className="flex flex-row items-center justify-between py-3">
408
+ <CardTitle className="text-base flex items-center gap-2">
409
+ <Shield className="h-4 w-4" />
410
+ Team Access Control
411
+ </CardTitle>
412
+ <Button
413
+ type="button"
414
+ variant="ghost"
415
+ size="sm"
416
+ onClick={() => setExpanded(false)}
417
+ >
418
+ Collapse
419
+ </Button>
420
+ </CardHeader>
421
+ <CardContent className="space-y-4">
422
+ {loading ? (
423
+ <div className="flex justify-center py-4">
424
+ <LoadingSpinner />
425
+ </div>
426
+ ) : (
427
+ <>
428
+ {/* Add team row */}
429
+ {canManageTeams && (
430
+ <div className="flex gap-2">
431
+ <Select
432
+ value={selectedTeamId}
433
+ onValueChange={setSelectedTeamId}
434
+ >
435
+ <SelectTrigger className="flex-1">
436
+ <SelectValue placeholder="Select team to add" />
437
+ </SelectTrigger>
438
+ <SelectContent>
439
+ {availableTeams.length === 0 ? (
440
+ <SelectItem value="_none" disabled>
441
+ No teams available
442
+ </SelectItem>
443
+ ) : (
444
+ availableTeams.map((team) => (
445
+ <SelectItem key={team.id} value={team.id}>
446
+ {team.name}
447
+ </SelectItem>
448
+ ))
449
+ )}
450
+ </SelectContent>
451
+ </Select>
452
+ <Button
453
+ type="button"
454
+ onClick={handleAddTeam}
455
+ disabled={!selectedTeamId || adding}
456
+ size="sm"
457
+ >
458
+ <Plus className="h-4 w-4 mr-1" />
459
+ Add
460
+ </Button>
461
+ </div>
462
+ )}
463
+
464
+ {/* Resource-level Team Only setting */}
465
+ {accessList.length > 0 && (
466
+ <div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
467
+ <div className="flex items-center gap-2">
468
+ <Lock className="h-4 w-4 text-muted-foreground" />
469
+ <div>
470
+ <Label htmlFor="team-only-card" className="cursor-pointer">
471
+ Team Only Mode
472
+ </Label>
473
+ <p className="text-xs text-muted-foreground">
474
+ When enabled, only team members can access (global
475
+ permissions bypassed)
476
+ </p>
477
+ </div>
478
+ </div>
479
+ <Toggle
480
+ checked={teamOnly}
481
+ onCheckedChange={handleUpdateSettings}
482
+ disabled={!canManageTeams}
483
+ />
484
+ </div>
485
+ )}
486
+
487
+ {/* Access list */}
488
+ {accessList.length === 0 ? (
489
+ <p className="text-sm text-muted-foreground text-center py-4 bg-muted/30 rounded-lg">
490
+ No team restrictions configured. All users with appropriate
491
+ permissions can access this resource.
492
+ </p>
493
+ ) : (
494
+ <div className="border rounded-lg divide-y">
495
+ {accessList.map((access) => (
496
+ <div
497
+ key={access.teamId}
498
+ className="p-3 flex items-center justify-between"
499
+ >
500
+ <div className="flex items-center gap-3">
501
+ <Users2 className="h-5 w-5 text-muted-foreground" />
502
+ <div>
503
+ <div className="font-medium">{access.teamName}</div>
504
+ <div className="flex gap-3 mt-1">
505
+ <label className="flex items-center gap-1.5 text-sm text-muted-foreground">
506
+ <Checkbox
507
+ checked={access.canRead}
508
+ onCheckedChange={(checked) =>
509
+ handleUpdateAccess(access.teamId, {
510
+ canRead: !!checked,
511
+ })
512
+ }
513
+ disabled={!canManageTeams}
514
+ />
515
+ <Eye className="h-3 w-3" />
516
+ Read
517
+ </label>
518
+ <label className="flex items-center gap-1.5 text-sm text-muted-foreground">
519
+ <Checkbox
520
+ checked={access.canManage}
521
+ onCheckedChange={(checked) =>
522
+ handleUpdateAccess(access.teamId, {
523
+ canManage: !!checked,
524
+ })
525
+ }
526
+ disabled={!canManageTeams}
527
+ />
528
+ <Settings className="h-3 w-3" />
529
+ Manage
530
+ </label>
531
+ </div>
532
+ </div>
533
+ </div>
534
+ {canManageTeams && (
535
+ <Button
536
+ type="button"
537
+ variant="ghost"
538
+ size="sm"
539
+ onClick={() => handleRemoveAccess(access.teamId)}
540
+ >
541
+ <Trash2 className="h-4 w-4 text-destructive" />
542
+ </Button>
543
+ )}
544
+ </div>
545
+ ))}
546
+ </div>
547
+ )}
548
+
549
+ <p className="text-xs text-muted-foreground">
550
+ <strong>Read:</strong> View this resource •{" "}
551
+ <strong>Manage:</strong> Edit this resource
552
+ </p>
553
+ </>
554
+ )}
555
+ </CardContent>
556
+ </Card>
557
+ );
558
+ };
559
+
560
+ export default TeamAccessEditor;