Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
LayoutDashboard,
Settings,
SlidersHorizontal,
Users,
Zap,
} from 'lucide-react';

Expand All @@ -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({
Expand Down
148 changes: 148 additions & 0 deletions web/src/components/settings/user-form-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit User' : 'New User'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
activeMutation.mutate();
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="user-name">Name</Label>
<Input
id="user-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Jane Smith"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="jane@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? 'Enter new password to change' : 'Password'}
required={!isEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<select
id="user-role"
value={role}
onChange={(e) => 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"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="superadmin">Superadmin</option>
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="inline-flex h-9 items-center rounded-md border border-input px-4 text-sm hover:bg-accent"
>
Cancel
</button>
<button
type="submit"
disabled={activeMutation.isPending}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{activeMutation.isPending ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</button>
</div>
{activeMutation.isError && (
<p className="text-sm text-destructive">{activeMutation.error.message}</p>
)}
</form>
</DialogContent>
</Dialog>
);
}
139 changes: 139 additions & 0 deletions web/src/components/settings/users-table.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [editUser, setEditUser] = useState<User | null>(null);

const deleteMutation = useMutation({
mutationFn: (id: string) => trpcClient.users.delete.mutate({ id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey });
setDeleteId(null);
},
});

return (
<>
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No users yet
</TableCell>
</TableRow>
)}
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell className="text-sm">{u.email}</TableCell>
<TableCell>
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}
</TableCell>
<TableCell>
<div className="flex gap-1">
<button
type="button"
onClick={() => setEditUser(u)}
className="p-1 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDeleteId(u.id)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this user account. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
className="bg-destructive text-white hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{editUser && (
<UserFormDialog
open={true}
onOpenChange={(open) => !open && setEditUser(null)}
user={editUser}
/>
)}
</>
);
}
2 changes: 2 additions & 0 deletions web/src/routes/route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -22,6 +23,7 @@ export const routeTree = rootRoute.addChildren([
projectDetailRoute,
settingsGeneralRoute,
settingsCredentialsRoute,
settingsUsersRoute,
globalDefaultsRoute,
globalDefinitionsRoute,
globalWebhookLogsRoute,
Expand Down
52 changes: 52 additions & 0 deletions web/src/routes/settings/users.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-sm text-muted-foreground">
Manage organization users and their roles.
</p>
</div>
<button
type="button"
onClick={() => 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
</button>
</div>

{usersQuery.isLoading && (
<div className="py-8 text-center text-muted-foreground">Loading users...</div>
)}

{usersQuery.isError && (
<div className="py-8 text-center text-destructive">
Failed to load users: {usersQuery.error.message}
</div>
)}

{usersQuery.data && <UsersTable users={usersQuery.data} />}

<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

export const settingsUsersRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings/users',
component: UsersPage,
});
Loading