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