diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 98007306..c1f1449d 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -12,6 +12,7 @@ import { LayoutDashboard, Settings, SlidersHorizontal, + Users, Zap, } from 'lucide-react'; @@ -32,6 +33,7 @@ const globalNav = [ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, + { to: '/settings/users' as const, label: 'Users', icon: Users }, ]; function NavLink({ diff --git a/web/src/components/settings/user-form-dialog.tsx b/web/src/components/settings/user-form-dialog.tsx new file mode 100644 index 00000000..67190204 --- /dev/null +++ b/web/src/components/settings/user-form-dialog.tsx @@ -0,0 +1,148 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +interface User { + id: string; + name: string; + email: string; + role: string; +} + +interface UserFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user?: User; +} + +export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps) { + const queryClient = useQueryClient(); + const isEdit = !!user; + + const [name, setName] = useState(user?.name ?? ''); + const [email, setEmail] = useState(user?.email ?? ''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'member' | 'admin' | 'superadmin'>( + (user?.role as 'member' | 'admin' | 'superadmin') ?? 'member', + ); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey }); + }; + + const createMutation = useMutation({ + mutationFn: () => + trpcClient.users.create.mutate({ + name, + email, + password, + role, + }), + onSuccess: () => { + invalidate(); + onOpenChange(false); + }, + }); + + const updateMutation = useMutation({ + mutationFn: () => + trpcClient.users.update.mutate({ + id: user?.id as string, + name, + email, + role, + ...(password ? { password } : {}), + }), + onSuccess: () => { + invalidate(); + onOpenChange(false); + }, + }); + + const activeMutation = isEdit ? updateMutation : createMutation; + + return ( + + + + {isEdit ? 'Edit User' : 'New User'} + + { + e.preventDefault(); + activeMutation.mutate(); + }} + className="space-y-4" + > + + Name + setName(e.target.value)} + placeholder="e.g. Jane Smith" + required + /> + + + Email + setEmail(e.target.value)} + placeholder="jane@example.com" + required + /> + + + Password + setPassword(e.target.value)} + placeholder={isEdit ? 'Enter new password to change' : 'Password'} + required={!isEdit} + /> + + + Role + setRole(e.target.value as 'member' | 'admin' | 'superadmin')} + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + > + Member + Admin + Superadmin + + + + onOpenChange(false)} + className="inline-flex h-9 items-center rounded-md border border-input px-4 text-sm hover:bg-accent" + > + Cancel + + + {activeMutation.isPending ? 'Saving...' : isEdit ? 'Update' : 'Create'} + + + {activeMutation.isError && ( + {activeMutation.error.message} + )} + + + + ); +} diff --git a/web/src/components/settings/users-table.tsx b/web/src/components/settings/users-table.tsx new file mode 100644 index 00000000..6da7046d --- /dev/null +++ b/web/src/components/settings/users-table.tsx @@ -0,0 +1,139 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog.js'; +import { Badge } from '@/components/ui/badge.js'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { UserFormDialog } from './user-form-dialog.js'; + +interface User { + id: string; + orgId: string; + name: string; + email: string; + role: string; + createdAt: string | null; + updatedAt: string | null; +} + +function roleVariant(role: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (role === 'superadmin') return 'destructive'; + if (role === 'admin') return 'default'; + return 'secondary'; +} + +export function UsersTable({ users }: { users: User[] }) { + const queryClient = useQueryClient(); + const [deleteId, setDeleteId] = useState(null); + const [editUser, setEditUser] = useState(null); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => trpcClient.users.delete.mutate({ id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey }); + setDeleteId(null); + }, + }); + + return ( + <> + + + + + Name + Email + Role + Created + + + + + {users.length === 0 && ( + + + No users yet + + + )} + {users.map((u) => ( + + {u.name} + {u.email} + + {u.role} + + + {u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'} + + + + setEditUser(u)} + className="p-1 text-muted-foreground hover:text-foreground" + > + + + setDeleteId(u.id)} + className="p-1 text-muted-foreground hover:text-destructive" + > + + + + + + ))} + + + + + !open && setDeleteId(null)}> + + + Delete User + + This will permanently delete this user account. This action cannot be undone. + + + + Cancel + deleteId && deleteMutation.mutate(deleteId)} + className="bg-destructive text-white hover:bg-destructive/90" + > + Delete + + + + + + {editUser && ( + !open && setEditUser(null)} + user={editUser} + /> + )} + > + ); +} diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 23dfc676..32470d3f 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -12,6 +12,7 @@ import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; import { settingsCredentialsRoute } from './settings/credentials.js'; import { settingsGeneralRoute } from './settings/general.js'; +import { settingsUsersRoute } from './settings/users.js'; import { workItemRunsRoute } from './work-items/$projectId.$workItemId.js'; export const routeTree = rootRoute.addChildren([ @@ -22,6 +23,7 @@ export const routeTree = rootRoute.addChildren([ projectDetailRoute, settingsGeneralRoute, settingsCredentialsRoute, + settingsUsersRoute, globalDefaultsRoute, globalDefinitionsRoute, globalWebhookLogsRoute, diff --git a/web/src/routes/settings/users.tsx b/web/src/routes/settings/users.tsx new file mode 100644 index 00000000..ec2f741a --- /dev/null +++ b/web/src/routes/settings/users.tsx @@ -0,0 +1,52 @@ +import { UserFormDialog } from '@/components/settings/user-form-dialog.js'; +import { UsersTable } from '@/components/settings/users-table.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { rootRoute } from '../__root.js'; + +function UsersPage() { + const [createOpen, setCreateOpen] = useState(false); + const usersQuery = useQuery(trpc.users.list.queryOptions()); + + return ( + + + + Users + + Manage organization users and their roles. + + + setCreateOpen(true)} + className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90" + > + New User + + + + {usersQuery.isLoading && ( + Loading users... + )} + + {usersQuery.isError && ( + + Failed to load users: {usersQuery.error.message} + + )} + + {usersQuery.data && } + + + + ); +} + +export const settingsUsersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/users', + component: UsersPage, +});
{activeMutation.error.message}
+ Manage organization users and their roles. +