mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-19 06:18:12 -04:00
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
|
import { Shield, ShieldAlert, UserCheck, Trash2, Search, AlertTriangle, Ban, KeyRound } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { authClient } from "~/client/lib/auth-client";
|
|
import { Button } from "~/client/components/ui/button";
|
|
import { cn } from "~/client/lib/utils";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
|
import { Badge } from "~/client/components/ui/badge";
|
|
import { Input } from "~/client/components/ui/input";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/client/components/ui/dialog";
|
|
import { CreateUserDialog } from "./create-user-dialog";
|
|
import {
|
|
getAdminUsersOptions,
|
|
getUserDeletionImpactOptions,
|
|
deleteUserAccountMutation,
|
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
|
|
|
export function UserManagement({ currentUser }: { currentUser: { id: string } | undefined | null }) {
|
|
const [search, setSearch] = useState("");
|
|
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
|
const [userToBan, setUserToBan] = useState<{ id: string; name: string; isBanned: boolean } | null>(null);
|
|
const [userToManageAccounts, setUserToManageAccounts] = useState<{
|
|
id: string;
|
|
name: string;
|
|
accounts: { id: string; providerId: string }[];
|
|
}>();
|
|
|
|
const { data: deletionImpact, isLoading: isLoadingImpact } = useQuery({
|
|
...getUserDeletionImpactOptions({ path: { userId: userToDelete ?? "" } }),
|
|
enabled: Boolean(userToDelete),
|
|
});
|
|
|
|
const { data } = useSuspenseQuery({ ...getAdminUsersOptions() });
|
|
|
|
const setRoleMutation = useMutation({
|
|
mutationFn: async ({ userId, role }: { userId: string; role: "user" | "admin" }) => {
|
|
const { error } = await authClient.admin.setRole({ userId, role });
|
|
if (error) throw error;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success("User role updated successfully");
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to update role", { description: error.message });
|
|
},
|
|
});
|
|
|
|
const toggleBanUserMutation = useMutation({
|
|
mutationFn: async ({ userId, ban }: { userId: string; ban: boolean }) => {
|
|
const { error } = ban ? await authClient.admin.banUser({ userId }) : await authClient.admin.unbanUser({ userId });
|
|
if (error) throw error;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success("User ban status updated successfully");
|
|
},
|
|
onMutate: () => {
|
|
setUserToBan(null);
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to update ban status", { description: error.message });
|
|
},
|
|
});
|
|
|
|
const deleteUser = useMutation({
|
|
mutationFn: async (userId: string) => {
|
|
const { error } = await authClient.admin.removeUser({ userId });
|
|
if (error) throw error;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success("User deleted successfully");
|
|
setUserToDelete(null);
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to delete user", { description: error.message });
|
|
},
|
|
});
|
|
|
|
const deleteAccount = useMutation({
|
|
...deleteUserAccountMutation(),
|
|
onSuccess: (_data, variables) => {
|
|
toast.success("Account removed successfully");
|
|
setUserToManageAccounts((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
accounts: prev.accounts.filter((a) => a.id !== variables.path.accountId),
|
|
};
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to remove account", { description: error.message });
|
|
},
|
|
});
|
|
|
|
const normalizedSearch = search.toLowerCase();
|
|
const filteredUsers = data.users.filter((user) => {
|
|
const name = user.name?.toLowerCase() ?? "";
|
|
const email = user.email.toLowerCase();
|
|
|
|
return name.includes(normalizedSearch) || email.includes(normalizedSearch);
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4 p-6">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users..."
|
|
className="pl-8"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<CreateUserDialog />
|
|
</div>
|
|
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>User</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<TableRow className={cn({ hidden: filteredUsers.length > 0 })}>
|
|
<TableCell colSpan={4} className="h-24 text-center">
|
|
No users found.
|
|
</TableCell>
|
|
</TableRow>
|
|
{filteredUsers.map((user) => {
|
|
const displayName = user.name ?? user.email;
|
|
const isCurrentUser = user.id === currentUser?.id;
|
|
const isBanned = Boolean(user.banned);
|
|
|
|
return (
|
|
<TableRow key={user.id}>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{displayName}</span>
|
|
<span className="text-sm text-muted-foreground">{user.email}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge>{user.role}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !isBanned })}>
|
|
Banned
|
|
</Badge>
|
|
<Badge variant="outline" className={cn("bg-success/10 text-success", { hidden: isBanned })}>
|
|
Active
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className={cn("flex justify-end gap-2")}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Demote to User"
|
|
className={cn({ hidden: user.role !== "admin" || isCurrentUser })}
|
|
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "user" })}
|
|
>
|
|
<ShieldAlert className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Promote to Admin"
|
|
className={cn({ hidden: user.role === "admin" || isCurrentUser })}
|
|
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })}
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Unban user"
|
|
className={cn({ hidden: !isBanned || isCurrentUser })}
|
|
onClick={() => setUserToBan({ id: user.id, name: displayName, isBanned: true })}
|
|
>
|
|
<UserCheck className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Ban user"
|
|
className={cn({ hidden: isBanned || isCurrentUser })}
|
|
onClick={() => setUserToBan({ id: user.id, name: displayName, isBanned: false })}
|
|
>
|
|
<Ban className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Delete User"
|
|
className={cn({ hidden: isCurrentUser })}
|
|
onClick={() => setUserToDelete(user.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Manage Accounts"
|
|
onClick={() =>
|
|
setUserToManageAccounts({
|
|
id: user.id,
|
|
name: displayName,
|
|
accounts: user.accounts,
|
|
})
|
|
}
|
|
>
|
|
<KeyRound className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<Dialog open={Boolean(userToDelete)} onOpenChange={(open) => !open && setUserToDelete(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
|
<DialogDescription>
|
|
This action cannot be undone. This will permanently delete the user account and remove their data from our
|
|
servers.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className={cn("space-y-4", { hidden: !deletionImpact?.organizations.length })}>
|
|
<div className="flex items-start gap-3 p-3 text-sm border rounded-lg bg-destructive/10 border-destructive/20 text-destructive">
|
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="font-semibold">Important: Data Deletion</p>
|
|
<p>
|
|
The following personal organizations and all their associated resources will be permanently deleted:
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 overflow-y-auto max-h-48">
|
|
{deletionImpact?.organizations.map((org) => (
|
|
<div key={org.id} className="p-3 border rounded-md bg-muted/50">
|
|
<p className="font-medium">{org.name}</p>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-muted-foreground">
|
|
<span>{org.resources.volumesCount} Volumes</span>
|
|
<span>{org.resources.repositoriesCount} Repositories</span>
|
|
<span>{org.resources.backupSchedulesCount} Backups</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn("text-center py-4", { hidden: !isLoadingImpact })}>
|
|
<p className="text-sm text-muted-foreground">Analyzing deletion impact...</p>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={isLoadingImpact || deleteUser.isPending}
|
|
onClick={() => deleteUser.mutate(userToDelete!)}
|
|
>
|
|
Delete User
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(userToBan)} onOpenChange={(open) => !open && setUserToBan(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{userToBan?.isBanned ? "Unban" : "Ban"} User</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to {userToBan?.isBanned ? "unban" : "ban"} {userToBan?.name}?
|
|
{userToBan?.isBanned
|
|
? " They will regain access to the system."
|
|
: " They will be immediately signed out and lose access."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setUserToBan(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
className={cn({ hidden: !userToBan?.isBanned })}
|
|
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan?.id ?? "", ban: false })}
|
|
>
|
|
Unban User
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
className={cn({ hidden: Boolean(userToBan?.isBanned) })}
|
|
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan?.id ?? "", ban: true })}
|
|
>
|
|
Ban User
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(userToManageAccounts)} onOpenChange={(open) => !open && setUserToManageAccounts(undefined)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Manage Accounts</DialogTitle>
|
|
<DialogDescription>Linked authentication accounts for {userToManageAccounts?.name}.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-2">
|
|
<p
|
|
className={cn("text-sm text-muted-foreground text-center py-4", {
|
|
hidden: userToManageAccounts?.accounts.length,
|
|
})}
|
|
>
|
|
No accounts linked.
|
|
</p>
|
|
{userToManageAccounts?.accounts.map((account) => (
|
|
<div key={account.id} className="flex items-center justify-between p-3 border rounded-md">
|
|
<span className="text-sm font-medium">{account.providerId}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Remove account"
|
|
disabled={deleteAccount.isPending}
|
|
onClick={() =>
|
|
deleteAccount.mutate({ path: { userId: userToManageAccounts.id, accountId: account.id } })
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setUserToManageAccounts(undefined)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|