@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,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 permission 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
+ };
package/src/index.tsx CHANGED
@@ -24,10 +24,7 @@ import { getAuthClientLazy } from "./lib/auth-client";
24
24
 
25
25
  import { usePermissions } from "./hooks/usePermissions";
26
26
 
27
- import {
28
- PermissionAction,
29
- qualifyPermissionId,
30
- } from "@checkstack/common";
27
+ import { PermissionAction, qualifyPermissionId } from "@checkstack/common";
31
28
  import { useNavigate } from "react-router-dom";
32
29
  import { Settings2, Key } from "lucide-react";
33
30
  import { DropdownMenuItem } from "@checkstack/ui";
@@ -125,7 +122,7 @@ class BetterAuthApi implements AuthApi {
125
122
 
126
123
  async signInWithSocial(provider: string) {
127
124
  // Use current origin as callback URL (works in dev and production)
128
- const frontendUrl = globalThis.location?.origin || "http://localhost:5173";
125
+ const frontendUrl = globalThis.location?.origin;
129
126
  await getAuthClientLazy().signIn.social({
130
127
  provider,
131
128
  callbackURL: frontendUrl,
@@ -171,6 +168,10 @@ class BetterAuthApi implements AuthApi {
171
168
  }
172
169
  }
173
170
 
171
+ // Re-export TeamAccessEditor for use in other plugins
172
+ export { TeamAccessEditor } from "./components/TeamAccessEditor";
173
+ export type { TeamAccessEditorProps } from "./components/TeamAccessEditor";
174
+
174
175
  export const authPlugin = createFrontendPlugin({
175
176
  metadata: pluginMetadata,
176
177
  apis: [