2035f73e7d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
ArrowUpDown,
|
||
Ban,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
KeyRound,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Search,
|
||
ShieldCheck,
|
||
ShieldOff,
|
||
Trash2,
|
||
UserCheck,
|
||
} from "lucide-react";
|
||
import { createContext, use, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
columnFilteringFeature,
|
||
createColumnHelper,
|
||
createFilteredRowModel,
|
||
createPaginatedRowModel,
|
||
createSortedRowModel,
|
||
globalFilteringFeature,
|
||
rowPaginationFeature,
|
||
rowSortingFeature,
|
||
tableFeatures,
|
||
useTable,
|
||
type SortFn,
|
||
} from "@tanstack/react-table";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { tr } from "@/i18n/tr";
|
||
import { fail, type ActionResult } from "@/lib/action-result";
|
||
import { cn } from "@/lib/utils";
|
||
import { formatDate } from "@/shared/format";
|
||
import type { SystemRole } from "@/shared/schemas/common";
|
||
import type { UserCreate, UserUpdate } from "@/shared/schemas/user";
|
||
import { compareTr, includesTr } from "@/shared/search";
|
||
import {
|
||
banUserAction,
|
||
createUserAction,
|
||
removeUserAction,
|
||
setPasswordAction,
|
||
setRoleAction,
|
||
unbanUserAction,
|
||
updateUserAction,
|
||
} from "../actions";
|
||
import { checkAdminAction, type AdminAction, type GuardViolation } from "../guards";
|
||
import type { AdminUser } from "../types";
|
||
import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs";
|
||
import { UserFormDialog } from "./user-form-dialog";
|
||
|
||
const t = tr.adminUsers;
|
||
|
||
// --- Tablo yapılandırması (modül seviyesinde sabit: v9 kararlı girdi ister) ---
|
||
|
||
const features = tableFeatures({
|
||
rowSortingFeature,
|
||
sortedRowModel: createSortedRowModel(),
|
||
columnFilteringFeature,
|
||
globalFilteringFeature,
|
||
filteredRowModel: createFilteredRowModel(),
|
||
rowPaginationFeature,
|
||
paginatedRowModel: createPaginatedRowModel(),
|
||
});
|
||
|
||
const byTr: SortFn<typeof features, AdminUser> = (a, b, id) =>
|
||
compareTr(String(a.getValue(id) ?? ""), String(b.getValue(id) ?? ""));
|
||
|
||
const helper = createColumnHelper<typeof features, AdminUser>();
|
||
|
||
/** Mobilde gizlenen sütunlar. */
|
||
const COLUMN_CLASS: Partial<Record<string, string>> = {
|
||
username: "hidden sm:table-cell",
|
||
email: "hidden lg:table-cell",
|
||
createdAt: "hidden md:table-cell",
|
||
actions: "w-12 text-right",
|
||
};
|
||
|
||
const PAGE_SIZES = [10, 20, 50] as const;
|
||
|
||
type DialogState =
|
||
{ kind: "create" } | { kind: "edit" | "password" | "ban" | "delete"; user: AdminUser } | null;
|
||
|
||
function SortableHeader({
|
||
label,
|
||
sorted,
|
||
onToggle,
|
||
}: {
|
||
label: string;
|
||
sorted: false | "asc" | "desc";
|
||
onToggle: ((event: unknown) => void) | undefined;
|
||
}) {
|
||
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="-ml-2.5"
|
||
onClick={onToggle}
|
||
aria-label={`${label} — sırala`}
|
||
>
|
||
{label}
|
||
<Icon className={cn("size-3.5", !sorted && "opacity-40")} />
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
type RowActionsApi = {
|
||
currentUserId: string;
|
||
guard: (action: AdminAction, target: AdminUser) => GuardViolation | null;
|
||
guardMessage: (v: GuardViolation) => string;
|
||
setDialog: (d: DialogState) => void;
|
||
setRole: (user: AdminUser, role: SystemRole) => void;
|
||
unbanUser: (user: AdminUser) => void;
|
||
};
|
||
|
||
/** Satır aksiyonları güncel durumu context'ten okur; sütunlar modül seviyesinde sabit kalır. */
|
||
const RowActionsContext = createContext<RowActionsApi | null>(null);
|
||
|
||
function useRowActions() {
|
||
const ctx = use(RowActionsContext);
|
||
if (!ctx) throw new Error("RowActionsContext eksik");
|
||
return ctx;
|
||
}
|
||
|
||
function NameCell({ user }: { user: AdminUser }) {
|
||
const { currentUserId } = useRowActions();
|
||
return (
|
||
<div className="flex min-w-0 flex-col">
|
||
<span className="truncate font-medium">
|
||
{user.name}
|
||
{user.id === currentUserId ? (
|
||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">({t.you})</span>
|
||
) : null}
|
||
</span>
|
||
<span className="truncate text-xs text-muted-foreground sm:hidden">@{user.username}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RowActions({ user }: { user: AdminUser }) {
|
||
const { guard, guardMessage, setDialog, setRole, unbanUser } = useRowActions();
|
||
const del = guard({ type: "delete" }, user);
|
||
const ban = guard({ type: "ban" }, user);
|
||
const toggleRole: SystemRole = user.role === "admin" ? "user" : "admin";
|
||
const role = guard({ type: "setRole", role: toggleRole }, user);
|
||
const blocked = del ?? role;
|
||
return (
|
||
// modal={false}: menüden açılan Dialog kapandıktan sonra sayfanın kilitli kalmasını önler.
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon-sm" aria-label={`${user.name} — ${tr.common.actions}`}>
|
||
<MoreHorizontal />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-56">
|
||
<DropdownMenuLabel className="truncate">{user.name}</DropdownMenuLabel>
|
||
<DropdownMenuItem onSelect={() => setDialog({ kind: "edit", user })}>
|
||
<Pencil />
|
||
{t.actions.edit}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem disabled={Boolean(role)} onSelect={() => setRole(user, toggleRole)}>
|
||
{toggleRole === "admin" ? <ShieldCheck /> : <ShieldOff />}
|
||
{toggleRole === "admin" ? t.actions.makeAdmin : t.actions.makeUser}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => setDialog({ kind: "password", user })}>
|
||
<KeyRound />
|
||
{t.actions.resetPassword}
|
||
</DropdownMenuItem>
|
||
{user.banned ? (
|
||
<DropdownMenuItem onSelect={() => unbanUser(user)}>
|
||
<UserCheck />
|
||
{t.actions.unban}
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem
|
||
disabled={Boolean(ban)}
|
||
onSelect={() => setDialog({ kind: "ban", user })}
|
||
>
|
||
<Ban />
|
||
{t.actions.ban}
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
disabled={Boolean(del)}
|
||
onSelect={() => setDialog({ kind: "delete", user })}
|
||
>
|
||
<Trash2 />
|
||
{t.actions.delete}
|
||
</DropdownMenuItem>
|
||
{blocked ? (
|
||
<p className="px-2 py-1.5 text-xs text-muted-foreground">{guardMessage(blocked)}</p>
|
||
) : null}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
const columns = helper.columns([
|
||
helper.accessor("name", {
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.name}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
sortFn: byTr,
|
||
cell: ({ row }) => <NameCell user={row.original} />,
|
||
}),
|
||
helper.accessor("username", {
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.username}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
sortFn: byTr,
|
||
cell: ({ getValue }) => <span className="text-muted-foreground">@{getValue()}</span>,
|
||
}),
|
||
helper.accessor((u) => u.email ?? "", {
|
||
id: "email",
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.email}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
sortFn: byTr,
|
||
cell: ({ getValue }) =>
|
||
getValue() || <span className="text-muted-foreground">{t.noEmail}</span>,
|
||
}),
|
||
helper.accessor("role", {
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.role}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
cell: ({ getValue }) => (
|
||
<Badge variant={getValue() === "admin" ? "default" : "secondary"}>
|
||
{tr.roles[getValue()]}
|
||
</Badge>
|
||
),
|
||
}),
|
||
helper.accessor("banned", {
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.status}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
cell: ({ row }) =>
|
||
row.original.banned ? (
|
||
<Badge variant="destructive" title={row.original.banReason}>
|
||
{t.status.banned}
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline" className="border-income/30 text-income">
|
||
{t.status.active}
|
||
</Badge>
|
||
),
|
||
}),
|
||
helper.accessor("createdAt", {
|
||
header: ({ column }) => (
|
||
<SortableHeader
|
||
label={t.columns.createdAt}
|
||
sorted={column.getIsSorted()}
|
||
onToggle={column.getToggleSortingHandler()}
|
||
/>
|
||
),
|
||
cell: ({ getValue }) => <span className="tabular-nums">{formatDate(getValue())}</span>,
|
||
}),
|
||
helper.display({
|
||
id: "actions",
|
||
header: () => <span className="sr-only">{tr.common.actions}</span>,
|
||
cell: ({ row }) => <RowActions user={row.original} />,
|
||
}),
|
||
]);
|
||
|
||
export function UsersTable({
|
||
users,
|
||
currentUserId,
|
||
}: {
|
||
users: AdminUser[];
|
||
currentUserId: string;
|
||
}) {
|
||
const [dialog, setDialog] = useState<DialogState>(null);
|
||
// Arayüzdeki kural kontrolü yalnızca erken geri bildirim içindir; asıl kontrol sunucuda.
|
||
const adminCount = users.filter((u) => u.role === "admin").length;
|
||
|
||
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null =>
|
||
checkAdminAction(action, { actorId: currentUserId, target, adminCount });
|
||
|
||
const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin);
|
||
|
||
/** Server Action'ı çağırır; başarıda bildirim gösterir. Sayfa revalidatePath ile tazelenir. */
|
||
async function run(promise: Promise<ActionResult>, success: string): Promise<ActionResult> {
|
||
try {
|
||
const result = await promise;
|
||
if (result.ok) toast.success(success);
|
||
return result;
|
||
} catch {
|
||
return fail(tr.common.genericError);
|
||
}
|
||
}
|
||
|
||
const createUser = (values: UserCreate) =>
|
||
run(createUserAction(values), t.toast.created(values.name));
|
||
|
||
const updateUser = (id: string, values: UserUpdate) =>
|
||
run(updateUserAction(id, values), t.toast.updated(values.name));
|
||
|
||
async function setRole(user: AdminUser, role: SystemRole) {
|
||
const result = await run(setRoleAction(user.id, role), t.toast.roleChanged(user.name));
|
||
if (!result.ok) toast.error(result.error);
|
||
}
|
||
|
||
const setPassword = (user: AdminUser, values: { password: string }) =>
|
||
run(setPasswordAction(user.id, values), t.toast.passwordSet(user.name));
|
||
|
||
const banUser = (user: AdminUser, values: { reason?: string }) =>
|
||
run(banUserAction(user.id, values), t.toast.banned(user.name));
|
||
|
||
async function unbanUser(user: AdminUser) {
|
||
const result = await run(unbanUserAction(user.id), t.toast.unbanned(user.name));
|
||
if (!result.ok) toast.error(result.error);
|
||
}
|
||
|
||
const removeUser = (user: AdminUser) =>
|
||
run(removeUserAction(user.id), t.toast.removed(user.name));
|
||
|
||
const table = useTable({
|
||
features,
|
||
columns,
|
||
data: users,
|
||
// Türkçe, büyük/küçük harf ve aksan duyarsız arama (ad, kullanıcı adı, e-posta).
|
||
globalFilterFn: (row, _columnId, filterValue) => {
|
||
const q = String(filterValue ?? "");
|
||
const u = row.original;
|
||
return includesTr(u.name, q) || includesTr(u.username, q) || includesTr(u.email, q);
|
||
},
|
||
getColumnCanGlobalFilter: (column) => column.id !== "actions",
|
||
initialState: {
|
||
sorting: [{ id: "createdAt", desc: true }],
|
||
pagination: { pageIndex: 0, pageSize: 10 },
|
||
},
|
||
});
|
||
|
||
const globalFilter = String(table.state.globalFilter ?? "");
|
||
const pageCount = Math.max(table.getPageCount(), 1);
|
||
const filteredCount = table.getFilteredRowModel().rows.length;
|
||
const selected = dialog && dialog.kind !== "create" ? dialog.user : undefined;
|
||
const close = (open: boolean) => !open && setDialog(null);
|
||
|
||
const rowActions: RowActionsApi = {
|
||
currentUserId,
|
||
guard,
|
||
guardMessage,
|
||
setDialog,
|
||
setRole,
|
||
unbanUser,
|
||
};
|
||
|
||
return (
|
||
<RowActionsContext value={rowActions}>
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<InputGroup className="max-w-sm flex-1">
|
||
<InputGroupAddon>
|
||
<Search />
|
||
</InputGroupAddon>
|
||
<InputGroupInput
|
||
type="search"
|
||
value={globalFilter}
|
||
onChange={(e) => table.setGlobalFilter(e.target.value)}
|
||
placeholder={t.searchPlaceholder}
|
||
aria-label={tr.common.search}
|
||
/>
|
||
</InputGroup>
|
||
<Button className="ml-auto" onClick={() => setDialog({ kind: "create" })}>
|
||
<Plus />
|
||
{t.newUser}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-xl border bg-card">
|
||
<Table>
|
||
<TableHeader>
|
||
{table.getHeaderGroups().map((group) => (
|
||
<TableRow key={group.id}>
|
||
{group.headers.map((header) => (
|
||
<TableHead key={header.id} className={COLUMN_CLASS[header.column.id]}>
|
||
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{table.getRowModel().rows.length ? (
|
||
table.getRowModel().rows.map((row) => (
|
||
<TableRow key={row.id} className={cn(row.original.banned && "opacity-70")}>
|
||
{row.getAllCells().map((cell) => (
|
||
<TableCell key={cell.id} className={COLUMN_CLASS[cell.column.id]}>
|
||
<table.FlexRender cell={cell} />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={columns.length}
|
||
className="h-24 text-center text-muted-foreground"
|
||
>
|
||
{tr.common.noResults}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
||
<span>{tr.common.totalRows(filteredCount)}</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="hidden sm:inline">{tr.common.rowsPerPage}</span>
|
||
<Select
|
||
value={String(table.state.pagination.pageSize)}
|
||
onValueChange={(v) => table.setPageSize(Number(v))}
|
||
>
|
||
<SelectTrigger size="sm" aria-label={tr.common.rowsPerPage} className="w-18">
|
||
<SelectValue>{table.state.pagination.pageSize}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PAGE_SIZES.map((n) => (
|
||
<SelectItem key={n} value={String(n)}>
|
||
{n}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="tabular-nums">
|
||
{tr.common.page(table.state.pagination.pageIndex + 1, pageCount)}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="icon-sm"
|
||
onClick={() => table.previousPage()}
|
||
disabled={!table.getCanPreviousPage()}
|
||
aria-label={tr.common.previous}
|
||
>
|
||
<ChevronLeft />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon-sm"
|
||
onClick={() => table.nextPage()}
|
||
disabled={!table.getCanNextPage()}
|
||
aria-label={tr.common.next}
|
||
>
|
||
<ChevronRight />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<UserFormDialog
|
||
open={dialog?.kind === "create" || dialog?.kind === "edit"}
|
||
onOpenChange={close}
|
||
user={dialog?.kind === "edit" ? dialog.user : undefined}
|
||
onCreate={createUser}
|
||
onUpdate={updateUser}
|
||
/>
|
||
<SetPasswordDialog
|
||
user={selected}
|
||
open={dialog?.kind === "password"}
|
||
onOpenChange={close}
|
||
onConfirm={setPassword}
|
||
/>
|
||
<BanUserDialog
|
||
user={selected}
|
||
open={dialog?.kind === "ban"}
|
||
onOpenChange={close}
|
||
onConfirm={banUser}
|
||
/>
|
||
<DeleteUserDialog
|
||
user={selected}
|
||
open={dialog?.kind === "delete"}
|
||
onOpenChange={close}
|
||
onConfirm={removeUser}
|
||
/>
|
||
</div>
|
||
</RowActionsContext>
|
||
);
|
||
}
|