@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,569 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardContent,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ Button,
14
+ Badge,
15
+ ConfirmationModal,
16
+ useToast,
17
+ Dialog,
18
+ DialogContent,
19
+ DialogDescription,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ DialogFooter,
23
+ Input,
24
+ Label,
25
+ Textarea,
26
+ LoadingSpinner,
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from "@checkstack/ui";
33
+ import {
34
+ Plus,
35
+ Edit,
36
+ Trash2,
37
+ Users2,
38
+ Crown,
39
+ UserPlus,
40
+ UserMinus,
41
+ } from "lucide-react";
42
+ import { useApi } from "@checkstack/frontend-api";
43
+ import { rpcApiRef } from "@checkstack/frontend-api";
44
+ import { AuthApi } from "@checkstack/auth-common";
45
+ import type { AuthUser } from "../api";
46
+
47
+ interface Team {
48
+ id: string;
49
+ name: string;
50
+ description?: string | null;
51
+ memberCount: number;
52
+ isManager: boolean;
53
+ }
54
+
55
+ interface TeamDetail {
56
+ id: string;
57
+ name: string;
58
+ description?: string | null;
59
+ members: Array<{ id: string; name: string; email: string }>;
60
+ managers: Array<{ id: string; name: string; email: string }>;
61
+ }
62
+
63
+ export interface TeamsTabProps {
64
+ users: AuthUser[];
65
+ canReadTeams: boolean;
66
+ canManageTeams: boolean;
67
+ onDataChange: () => Promise<void>;
68
+ }
69
+
70
+ export const TeamsTab: React.FC<TeamsTabProps> = ({
71
+ users,
72
+ canReadTeams,
73
+ canManageTeams,
74
+ onDataChange,
75
+ }) => {
76
+ const rpcApi = useApi(rpcApiRef);
77
+ const authClient = rpcApi.forPlugin(AuthApi);
78
+ const toast = useToast();
79
+
80
+ const [teams, setTeams] = useState<Team[]>([]);
81
+ const [loading, setLoading] = useState(true);
82
+ const [teamToDelete, setTeamToDelete] = useState<string>();
83
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
84
+ const [editingTeam, setEditingTeam] = useState<Team | undefined>();
85
+ const [membersDialogOpen, setMembersDialogOpen] = useState(false);
86
+ const [selectedTeamDetail, setSelectedTeamDetail] = useState<TeamDetail>();
87
+ const [membersLoading, setMembersLoading] = useState(false);
88
+
89
+ // Team form state
90
+ const [formName, setFormName] = useState("");
91
+ const [formDescription, setFormDescription] = useState("");
92
+ const [formSaving, setFormSaving] = useState(false);
93
+
94
+ // Member management state
95
+ const [selectedUserId, setSelectedUserId] = useState("");
96
+ const [addingMember, setAddingMember] = useState(false);
97
+
98
+ const loadTeams = useCallback(async () => {
99
+ setLoading(true);
100
+ try {
101
+ const teamsData = await authClient.getTeams();
102
+ setTeams(teamsData);
103
+ } catch (error) {
104
+ toast.error(
105
+ error instanceof Error ? error.message : "Failed to load teams"
106
+ );
107
+ } finally {
108
+ setLoading(false);
109
+ }
110
+ }, [authClient, toast]);
111
+
112
+ useEffect(() => {
113
+ loadTeams();
114
+ }, [loadTeams]);
115
+
116
+ const handleCreateTeam = () => {
117
+ setEditingTeam(undefined);
118
+ setFormName("");
119
+ setFormDescription("");
120
+ setEditDialogOpen(true);
121
+ };
122
+
123
+ const handleEditTeam = (team: Team) => {
124
+ setEditingTeam(team);
125
+ setFormName(team.name);
126
+ setFormDescription(team.description ?? "");
127
+ setEditDialogOpen(true);
128
+ };
129
+
130
+ const handleSaveTeam = async () => {
131
+ if (!formName.trim()) {
132
+ toast.error("Team name is required");
133
+ return;
134
+ }
135
+
136
+ setFormSaving(true);
137
+ try {
138
+ if (editingTeam) {
139
+ await authClient.updateTeam({
140
+ id: editingTeam.id,
141
+ name: formName,
142
+ description: formDescription || undefined,
143
+ });
144
+ toast.success("Team updated successfully");
145
+ } else {
146
+ await authClient.createTeam({
147
+ name: formName,
148
+ description: formDescription || undefined,
149
+ });
150
+ toast.success("Team created successfully");
151
+ }
152
+ setEditDialogOpen(false);
153
+ await loadTeams();
154
+ await onDataChange();
155
+ } catch (error) {
156
+ toast.error(
157
+ error instanceof Error ? error.message : "Failed to save team"
158
+ );
159
+ } finally {
160
+ setFormSaving(false);
161
+ }
162
+ };
163
+
164
+ const handleDeleteTeam = async () => {
165
+ if (!teamToDelete) return;
166
+ try {
167
+ await authClient.deleteTeam(teamToDelete);
168
+ toast.success("Team deleted successfully");
169
+ setTeamToDelete(undefined);
170
+ await loadTeams();
171
+ await onDataChange();
172
+ } catch (error) {
173
+ toast.error(
174
+ error instanceof Error ? error.message : "Failed to delete team"
175
+ );
176
+ }
177
+ };
178
+
179
+ const openMembersDialog = async (teamId: string) => {
180
+ setMembersLoading(true);
181
+ setMembersDialogOpen(true);
182
+ try {
183
+ const detail = await authClient.getTeam({ teamId });
184
+ setSelectedTeamDetail(detail ?? undefined);
185
+ } catch (error) {
186
+ toast.error(
187
+ error instanceof Error ? error.message : "Failed to load team details"
188
+ );
189
+ setMembersDialogOpen(false);
190
+ } finally {
191
+ setMembersLoading(false);
192
+ }
193
+ };
194
+
195
+ const handleAddMember = async () => {
196
+ if (!selectedTeamDetail || !selectedUserId) return;
197
+
198
+ setAddingMember(true);
199
+ try {
200
+ await authClient.addUserToTeam({
201
+ teamId: selectedTeamDetail.id,
202
+ userId: selectedUserId,
203
+ });
204
+ toast.success("Member added successfully");
205
+ // Reload team details
206
+ const detail = await authClient.getTeam({
207
+ teamId: selectedTeamDetail.id,
208
+ });
209
+ setSelectedTeamDetail(detail ?? undefined);
210
+ setSelectedUserId("");
211
+ await loadTeams();
212
+ } catch (error) {
213
+ toast.error(
214
+ error instanceof Error ? error.message : "Failed to add member"
215
+ );
216
+ } finally {
217
+ setAddingMember(false);
218
+ }
219
+ };
220
+
221
+ const handleRemoveMember = async (userId: string) => {
222
+ if (!selectedTeamDetail) return;
223
+
224
+ try {
225
+ await authClient.removeUserFromTeam({
226
+ teamId: selectedTeamDetail.id,
227
+ userId,
228
+ });
229
+ toast.success("Member removed");
230
+ // Reload team details
231
+ const detail = await authClient.getTeam({
232
+ teamId: selectedTeamDetail.id,
233
+ });
234
+ setSelectedTeamDetail(detail ?? undefined);
235
+ await loadTeams();
236
+ } catch (error) {
237
+ toast.error(
238
+ error instanceof Error ? error.message : "Failed to remove member"
239
+ );
240
+ }
241
+ };
242
+
243
+ const handleToggleManager = async (
244
+ userId: string,
245
+ isCurrentlyManager: boolean
246
+ ) => {
247
+ if (!selectedTeamDetail) return;
248
+
249
+ try {
250
+ if (isCurrentlyManager) {
251
+ await authClient.removeTeamManager({
252
+ teamId: selectedTeamDetail.id,
253
+ userId,
254
+ });
255
+ toast.success("Manager role removed");
256
+ } else {
257
+ await authClient.addTeamManager({
258
+ teamId: selectedTeamDetail.id,
259
+ userId,
260
+ });
261
+ toast.success("Member promoted to manager");
262
+ }
263
+ // Reload team details
264
+ const detail = await authClient.getTeam({
265
+ teamId: selectedTeamDetail.id,
266
+ });
267
+ setSelectedTeamDetail(detail ?? undefined);
268
+ } catch (error) {
269
+ toast.error(
270
+ error instanceof Error
271
+ ? error.message
272
+ : "Failed to update manager status"
273
+ );
274
+ }
275
+ };
276
+
277
+ // Get users not already in the team
278
+ const availableUsers = selectedTeamDetail
279
+ ? users.filter(
280
+ (u) => !selectedTeamDetail.members.some((m) => m.id === u.id)
281
+ )
282
+ : [];
283
+
284
+ return (
285
+ <>
286
+ <Card>
287
+ <CardHeader className="flex flex-row items-center justify-between">
288
+ <CardTitle>Team Management</CardTitle>
289
+ {canManageTeams && (
290
+ <Button onClick={handleCreateTeam} size="sm">
291
+ <Plus className="h-4 w-4 mr-2" />
292
+ Create Team
293
+ </Button>
294
+ )}
295
+ </CardHeader>
296
+ <CardContent>
297
+ {canReadTeams ? (
298
+ loading ? (
299
+ <div className="flex justify-center py-8">
300
+ <LoadingSpinner />
301
+ </div>
302
+ ) : teams.length === 0 ? (
303
+ <p className="text-muted-foreground">No teams found.</p>
304
+ ) : (
305
+ <Table>
306
+ <TableHeader>
307
+ <TableRow>
308
+ <TableHead>Team</TableHead>
309
+ <TableHead>Members</TableHead>
310
+ <TableHead className="text-right">Actions</TableHead>
311
+ </TableRow>
312
+ </TableHeader>
313
+ <TableBody>
314
+ {teams.map((team) => (
315
+ <TableRow key={team.id}>
316
+ <TableCell>
317
+ <div className="flex flex-col">
318
+ <span className="font-medium">{team.name}</span>
319
+ {team.description && (
320
+ <span className="text-sm text-muted-foreground">
321
+ {team.description}
322
+ </span>
323
+ )}
324
+ {team.isManager && (
325
+ <Badge variant="secondary" className="mt-1 w-fit">
326
+ <Crown className="h-3 w-3 mr-1" />
327
+ Manager
328
+ </Badge>
329
+ )}
330
+ </div>
331
+ </TableCell>
332
+ <TableCell>
333
+ <span className="text-muted-foreground">
334
+ {team.memberCount} member
335
+ {team.memberCount === 1 ? "" : "s"}
336
+ </span>
337
+ </TableCell>
338
+ <TableCell className="text-right">
339
+ <div className="flex justify-end gap-2">
340
+ <Button
341
+ variant="ghost"
342
+ size="sm"
343
+ onClick={() => openMembersDialog(team.id)}
344
+ title="Manage members"
345
+ >
346
+ <Users2 className="h-4 w-4" />
347
+ </Button>
348
+ <Button
349
+ variant="ghost"
350
+ size="sm"
351
+ onClick={() => handleEditTeam(team)}
352
+ disabled={!team.isManager && !canManageTeams}
353
+ >
354
+ <Edit className="h-4 w-4" />
355
+ </Button>
356
+ <Button
357
+ variant="ghost"
358
+ size="sm"
359
+ onClick={() => setTeamToDelete(team.id)}
360
+ disabled={!canManageTeams}
361
+ >
362
+ <Trash2 className="h-4 w-4" />
363
+ </Button>
364
+ </div>
365
+ </TableCell>
366
+ </TableRow>
367
+ ))}
368
+ </TableBody>
369
+ </Table>
370
+ )
371
+ ) : (
372
+ <p className="text-muted-foreground">
373
+ You don't have access to view teams.
374
+ </p>
375
+ )}
376
+ </CardContent>
377
+ </Card>
378
+
379
+ {/* Create/Edit Team Dialog */}
380
+ <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
381
+ <DialogContent>
382
+ <DialogHeader>
383
+ <DialogTitle>
384
+ {editingTeam ? "Edit Team" : "Create Team"}
385
+ </DialogTitle>
386
+ <DialogDescription>
387
+ {editingTeam
388
+ ? "Update the team details below."
389
+ : "Create a new team to organize resource access."}
390
+ </DialogDescription>
391
+ </DialogHeader>
392
+ <div className="space-y-4">
393
+ <div>
394
+ <Label htmlFor="name">Name</Label>
395
+ <Input
396
+ id="name"
397
+ value={formName}
398
+ onChange={(e) => setFormName(e.target.value)}
399
+ placeholder="Team name"
400
+ />
401
+ </div>
402
+ <div>
403
+ <Label htmlFor="description">Description</Label>
404
+ <Textarea
405
+ id="description"
406
+ value={formDescription}
407
+ onChange={(e) => setFormDescription(e.target.value)}
408
+ placeholder="Optional description"
409
+ rows={3}
410
+ />
411
+ </div>
412
+ </div>
413
+ <DialogFooter>
414
+ <Button
415
+ variant="outline"
416
+ onClick={() => setEditDialogOpen(false)}
417
+ disabled={formSaving}
418
+ >
419
+ Cancel
420
+ </Button>
421
+ <Button onClick={handleSaveTeam} disabled={formSaving}>
422
+ {formSaving ? "Saving..." : editingTeam ? "Update" : "Create"}
423
+ </Button>
424
+ </DialogFooter>
425
+ </DialogContent>
426
+ </Dialog>
427
+
428
+ {/* Members Management Dialog */}
429
+ <Dialog open={membersDialogOpen} onOpenChange={setMembersDialogOpen}>
430
+ <DialogContent className="max-w-lg">
431
+ <DialogHeader>
432
+ <DialogTitle>
433
+ {selectedTeamDetail?.name ?? "Team"} Members
434
+ </DialogTitle>
435
+ <DialogDescription>
436
+ Manage team membership and assign managers.
437
+ </DialogDescription>
438
+ </DialogHeader>
439
+
440
+ {membersLoading ? (
441
+ <div className="flex justify-center py-8">
442
+ <LoadingSpinner />
443
+ </div>
444
+ ) : selectedTeamDetail ? (
445
+ <div className="space-y-4">
446
+ {/* Add Member Form */}
447
+ {(selectedTeamDetail.managers.some((m) =>
448
+ users.some((u) => u.id === m.id)
449
+ ) ||
450
+ canManageTeams) && (
451
+ <div className="flex gap-2">
452
+ <Select
453
+ value={selectedUserId}
454
+ onValueChange={setSelectedUserId}
455
+ >
456
+ <SelectTrigger className="flex-1">
457
+ <SelectValue placeholder="Select user to add" />
458
+ </SelectTrigger>
459
+ <SelectContent>
460
+ {availableUsers.length === 0 ? (
461
+ <SelectItem value="_none" disabled>
462
+ No available users
463
+ </SelectItem>
464
+ ) : (
465
+ availableUsers.map((user) => (
466
+ <SelectItem key={user.id} value={user.id}>
467
+ {user.name} ({user.email})
468
+ </SelectItem>
469
+ ))
470
+ )}
471
+ </SelectContent>
472
+ </Select>
473
+ <Button
474
+ onClick={handleAddMember}
475
+ disabled={!selectedUserId || addingMember}
476
+ size="sm"
477
+ >
478
+ <UserPlus className="h-4 w-4 mr-1" />
479
+ Add
480
+ </Button>
481
+ </div>
482
+ )}
483
+
484
+ {/* Member List */}
485
+ <div className="border rounded-lg divide-y max-h-64 overflow-y-auto">
486
+ {selectedTeamDetail.members.length === 0 ? (
487
+ <p className="text-muted-foreground text-center py-4">
488
+ No members yet
489
+ </p>
490
+ ) : (
491
+ selectedTeamDetail.members.map((member) => {
492
+ const isManager = selectedTeamDetail.managers.some(
493
+ (m) => m.id === member.id
494
+ );
495
+ return (
496
+ <div
497
+ key={member.id}
498
+ className="flex items-center justify-between p-3"
499
+ >
500
+ <div>
501
+ <div className="font-medium">{member.name}</div>
502
+ <div className="text-sm text-muted-foreground">
503
+ {member.email}
504
+ </div>
505
+ </div>
506
+ <div className="flex items-center gap-2">
507
+ {isManager && (
508
+ <Badge variant="secondary">
509
+ <Crown className="h-3 w-3 mr-1" />
510
+ Manager
511
+ </Badge>
512
+ )}
513
+ {canManageTeams && (
514
+ <>
515
+ <Button
516
+ variant="ghost"
517
+ size="sm"
518
+ onClick={() =>
519
+ handleToggleManager(member.id, isManager)
520
+ }
521
+ title={
522
+ isManager
523
+ ? "Remove manager role"
524
+ : "Promote to manager"
525
+ }
526
+ >
527
+ <Crown
528
+ className={`h-4 w-4 ${
529
+ isManager ? "text-warning" : ""
530
+ }`}
531
+ />
532
+ </Button>
533
+ <Button
534
+ variant="ghost"
535
+ size="sm"
536
+ onClick={() => handleRemoveMember(member.id)}
537
+ title="Remove from team"
538
+ >
539
+ <UserMinus className="h-4 w-4" />
540
+ </Button>
541
+ </>
542
+ )}
543
+ </div>
544
+ </div>
545
+ );
546
+ })
547
+ )}
548
+ </div>
549
+ </div>
550
+ ) : undefined}
551
+
552
+ <DialogFooter>
553
+ <Button onClick={() => setMembersDialogOpen(false)}>Close</Button>
554
+ </DialogFooter>
555
+ </DialogContent>
556
+ </Dialog>
557
+
558
+ {/* Delete Confirmation Dialog */}
559
+ <ConfirmationModal
560
+ isOpen={!!teamToDelete}
561
+ onClose={() => setTeamToDelete(undefined)}
562
+ onConfirm={handleDeleteTeam}
563
+ title="Delete Team"
564
+ message="Are you sure you want to delete this team? All resource access grants associated with this team will be removed. This action cannot be undone."
565
+ variant="danger"
566
+ />
567
+ </>
568
+ );
569
+ };
@@ -130,7 +130,7 @@ export const UsersTab: React.FC<UsersTabProps> = ({
130
130
  <AlertDescription>
131
131
  You cannot modify roles for your own account. This security
132
132
  measure prevents accidental self-lockout from the system and
133
- permission elevation.
133
+ access elevation.
134
134
  </AlertDescription>
135
135
  </Alert>
136
136
  {canReadUsers ? (
@@ -210,7 +210,7 @@ export const UsersTab: React.FC<UsersTabProps> = ({
210
210
  )
211
211
  ) : (
212
212
  <p className="text-muted-foreground">
213
- You don't have permission to list users.
213
+ You don't have access to list users.
214
214
  </p>
215
215
  )}
216
216
  </CardContent>
@@ -3,10 +3,10 @@ import { useAuthClient } from "../lib/auth-client";
3
3
  import { rpcApiRef, useApi } from "@checkstack/frontend-api";
4
4
  import { AuthApi } from "@checkstack/auth-common";
5
5
 
6
- export const usePermissions = () => {
6
+ export const useAccessRules = () => {
7
7
  const authClient = useAuthClient();
8
8
  const { data: session, isPending: sessionPending } = authClient.useSession();
9
- const [permissions, setPermissions] = useState<string[]>([]);
9
+ const [accessRules, setAccessRules] = useState<string[]>([]);
10
10
  const [loading, setLoading] = useState(true);
11
11
  const rpcApi = useApi(rpcApiRef);
12
12
 
@@ -18,26 +18,26 @@ export const usePermissions = () => {
18
18
  }
19
19
 
20
20
  if (!session?.user) {
21
- setPermissions([]);
21
+ setAccessRules([]);
22
22
  setLoading(false);
23
23
  return;
24
24
  }
25
25
 
26
- const fetchPermissions = async () => {
26
+ const fetchAccessRules = async () => {
27
27
  try {
28
28
  const authRpc = rpcApi.forPlugin(AuthApi);
29
- const data = await authRpc.permissions();
30
- if (Array.isArray(data.permissions)) {
31
- setPermissions(data.permissions);
29
+ const data = await authRpc.accessRules();
30
+ if (Array.isArray(data.accessRules)) {
31
+ setAccessRules(data.accessRules);
32
32
  }
33
33
  } catch (error) {
34
- console.error("Failed to fetch permissions", error);
34
+ console.error("Failed to fetch access rules", error);
35
35
  } finally {
36
36
  setLoading(false);
37
37
  }
38
38
  };
39
- fetchPermissions();
39
+ fetchAccessRules();
40
40
  }, [session?.user?.id, sessionPending, rpcApi]);
41
41
 
42
- return { permissions, loading };
42
+ return { accessRules, loading };
43
43
  };