feat(admin): kullanıcı yönetimi tablosu ve diyalogları (mock)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:35:10 +03:00
parent 1cf9fd9d8c
commit 3b94bceaf0
9 changed files with 1282 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header";
import { UsersTable } from "@/features/admin-users/components/users-table";
import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { tr } from "@/i18n/tr";
export const metadata: Metadata = { title: tr.adminUsers.title };
export default function AdminUsersPage() {
// FAZ 3: const users = await listUsers() (Better Auth admin API)
return (
<>
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
<UsersTable initialUsers={MOCK_ADMIN_USERS} currentUserId={MOCK_SESSION_USER.id} />
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { notFound } from "next/navigation";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
/**
* Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez).
* FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol.
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
if (MOCK_SESSION_USER.role !== "admin") notFound();
return children;
}
@@ -0,0 +1,219 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { PasswordInput } from "@/components/password-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { tr } from "@/i18n/tr";
import type { ActionResult } from "@/lib/action-result";
import {
banUserSchema,
setPasswordSchema,
type BanUserInput,
type SetPasswordInput,
} from "@/shared/schemas/user";
import type { AdminUser } from "../types";
const t = tr.adminUsers;
type DialogProps<T> = {
user: AdminUser | undefined;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (user: AdminUser, values: T) => Promise<ActionResult>;
};
export function SetPasswordDialog({
user,
open,
onOpenChange,
onConfirm,
}: DialogProps<{ password: string }>) {
const form = useForm<SetPasswordInput>({
resolver: zodResolver(setPasswordSchema),
defaultValues: { password: "" },
});
useEffect(() => {
if (open) form.reset({ password: "" });
}, [open, form]);
async function onSubmit(values: SetPasswordInput) {
if (!user) return;
const result = await onConfirm(user, values);
if (result.ok) onOpenChange(false);
else form.setError("root", { message: result.error });
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={t.password.title}
description={user ? t.password.description(user.name) : undefined}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="password-form" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{t.password.confirm}
</Button>
</>
}
>
<form id="password-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="new-password">{t.password.newPassword}</FieldLabel>
<PasswordInput
{...field}
id="new-password"
autoComplete="new-password"
aria-invalid={fieldState.invalid}
aria-describedby="new-password-hint"
/>
<FieldDescription id="new-password-hint">{t.form.passwordHint}</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
export function BanUserDialog({
user,
open,
onOpenChange,
onConfirm,
}: DialogProps<{ reason?: string }>) {
const form = useForm<BanUserInput, unknown, { reason?: string }>({
resolver: zodResolver(banUserSchema),
defaultValues: { reason: "" },
});
useEffect(() => {
if (open) form.reset({ reason: "" });
}, [open, form]);
async function onSubmit(values: { reason?: string }) {
if (!user) return;
const result = await onConfirm(user, values);
if (result.ok) onOpenChange(false);
else form.setError("root", { message: result.error });
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={t.ban.title}
description={user ? t.ban.description(user.name) : undefined}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button
type="submit"
form="ban-form"
variant="destructive"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{t.ban.confirm}
</Button>
</>
}
>
<form id="ban-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="reason"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="ban-reason">{t.ban.reason}</FieldLabel>
<Textarea
{...field}
value={field.value ?? ""}
id="ban-reason"
rows={3}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
export function DeleteUserDialog({ user, open, onOpenChange, onConfirm }: DialogProps<void>) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleOpenChange(next: boolean) {
if (!next) setError(null);
onOpenChange(next);
}
async function confirm() {
if (!user) return;
setPending(true);
const result = await onConfirm(user, undefined);
setPending(false);
if (result.ok) handleOpenChange(false);
else setError(result.error);
}
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.remove.title}</AlertDialogTitle>
<AlertDialogDescription>
{user ? t.remove.description(user.name) : null}
</AlertDialogDescription>
</AlertDialogHeader>
{error ? <FieldError>{error}</FieldError> : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>{tr.common.cancel}</AlertDialogCancel>
{/* AlertDialogAction otomatik kapanır; sonuç beklenmesi için normal düğme. */}
<Button variant="destructive" onClick={confirm} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
{t.remove.confirm}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,226 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect } from "react";
import { Controller, useForm, type Resolver } from "react-hook-form";
import { PasswordInput } from "@/components/password-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import type { ActionResult } from "@/lib/action-result";
import { SYSTEM_ROLES } from "@/shared/schemas/common";
import {
userCreateSchema,
userUpdateSchema,
type UserCreate,
type UserCreateInput,
type UserUpdate,
} from "@/shared/schemas/user";
import type { AdminUser } from "../types";
const t = tr.adminUsers.form;
const FORM_ID = "user-form";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Verilirse düzenleme modu. */
user?: AdminUser;
onCreate: (values: UserCreate) => Promise<ActionResult>;
onUpdate: (id: string, values: UserUpdate) => Promise<ActionResult>;
};
type FormOutput = UserCreate | UserUpdate;
const createResolver: Resolver<UserCreateInput, unknown, FormOutput> =
zodResolver(userCreateSchema);
/**
* Düzenleme şeması şifre alanını içermez; zod nesnesi bilinmeyen alanı (boş "password") atar.
* Form değer şekli aynı kaldığı için tip dönüşümü güvenlidir.
*/
const updateResolver = zodResolver(userUpdateSchema) as unknown as Resolver<
UserCreateInput,
unknown,
FormOutput
>;
const empty: UserCreateInput = { name: "", username: "", email: "", role: "user", password: "" };
export function UserFormDialog({ open, onOpenChange, user, onCreate, onUpdate }: Props) {
const isEdit = Boolean(user);
const form = useForm<UserCreateInput, unknown, FormOutput>({
resolver: isEdit ? updateResolver : createResolver,
defaultValues: empty,
});
useEffect(() => {
if (open) {
form.reset(
user
? {
name: user.name,
username: user.username,
email: user.email ?? "",
role: user.role,
password: "",
}
: empty,
);
}
}, [open, user, form]);
async function onSubmit(values: FormOutput) {
const result = user ? await onUpdate(user.id, values) : await onCreate(values as UserCreate);
if (result.ok) {
onOpenChange(false);
return;
}
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (message) form.setError(name as keyof UserCreateInput, { message });
}
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
const submitting = form.formState.isSubmitting;
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t.editTitle : t.createTitle}
description={isEdit ? t.editDescription : t.createDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form={FORM_ID} disabled={submitting}>
{submitting ? <Loader2 className="animate-spin" /> : null}
{isEdit ? tr.common.save : tr.common.create}
</Button>
</>
}
>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="user-name">{t.name}</FieldLabel>
<Input
{...field}
id="user-name"
autoComplete="off"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="username"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="user-username">{t.username}</FieldLabel>
<Input
{...field}
id="user-username"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
aria-invalid={fieldState.invalid}
aria-describedby="user-username-hint"
/>
<FieldDescription id="user-username-hint">{t.usernameHint}</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="user-email">{t.email}</FieldLabel>
<Input
{...field}
value={field.value ?? ""}
id="user-email"
type="email"
inputMode="email"
autoComplete="off"
aria-invalid={fieldState.invalid}
aria-describedby="user-email-hint"
/>
<FieldDescription id="user-email-hint">{t.emailHint}</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="role"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="user-role">{t.role}</FieldLabel>
<Select name={field.name} value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="user-role"
aria-invalid={fieldState.invalid}
onBlur={field.onBlur}
>
<SelectValue>{tr.roles[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{SYSTEM_ROLES.map((r) => (
<SelectItem key={r} value={r}>
{tr.roles[r]}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{!isEdit ? (
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="user-password">{t.password}</FieldLabel>
<PasswordInput
{...field}
id="user-password"
autoComplete="new-password"
aria-invalid={fieldState.invalid}
aria-describedby="user-password-hint"
/>
<FieldDescription id="user-password-hint">{t.passwordHint}</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,572 @@
"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, ok, 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 {
checkAdminAction,
findUniqueConflicts,
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({
initialUsers,
currentUserId,
}: {
initialUsers: AdminUser[];
currentUserId: string;
}) {
// FAZ 3: yerel durum yerine Server Action + revalidatePath.
const [users, setUsers] = useState(initialUsers);
const [dialog, setDialog] = useState<DialogState>(null);
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);
// --- Mock işlemler (FAZ 3'te Server Action'lara taşınacak; aynı ActionResult sözleşmesi) ---
async function createUser(values: UserCreate): Promise<ActionResult> {
const conflicts = findUniqueConflicts(users, values);
if (conflicts.username || conflicts.email) {
return fail(t.errors.checkFields, {
username: conflicts.username && t.errors.usernameTaken,
email: conflicts.email && t.errors.emailTaken,
});
}
const { password: _password, ...rest } = values;
setUsers((prev) => [
{
...rest,
id: `u_${crypto.randomUUID().slice(0, 8)}`,
banned: false,
createdAt: new Date().toISOString(),
},
...prev,
]);
toast.success(t.toast.created(values.name));
return ok(undefined);
}
async function updateUser(id: string, values: UserUpdate): Promise<ActionResult> {
const target = users.find((u) => u.id === id);
if (!target) return fail(tr.common.genericError);
const conflicts = findUniqueConflicts(users, values, id);
if (conflicts.username || conflicts.email) {
return fail(t.errors.checkFields, {
username: conflicts.username && t.errors.usernameTaken,
email: conflicts.email && t.errors.emailTaken,
});
}
const v =
values.role !== target.role ? guard({ type: "setRole", role: values.role }, target) : null;
if (v) return fail(guardMessage(v), { role: guardMessage(v) });
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...values } : u)));
toast.success(t.toast.updated(values.name));
return ok(undefined);
}
function setRole(user: AdminUser, role: SystemRole) {
const v = guard({ type: "setRole", role }, user);
if (v) return void toast.error(guardMessage(v));
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role } : u)));
toast.success(t.toast.roleChanged(user.name));
}
async function setPassword(user: AdminUser): Promise<ActionResult> {
toast.success(t.toast.passwordSet(user.name));
return ok(undefined);
}
async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise<ActionResult> {
const v = guard({ type: "ban" }, user);
if (v) return fail(guardMessage(v));
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, banned: true, banReason: reason } : u)),
);
toast.success(t.toast.banned(user.name));
return ok(undefined);
}
function unbanUser(user: AdminUser) {
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, banned: false, banReason: undefined } : u)),
);
toast.success(t.toast.unbanned(user.name));
}
async function removeUser(user: AdminUser): Promise<ActionResult> {
const v = guard({ type: "delete" }, user);
if (v) return fail(guardMessage(v));
setUsers((prev) => prev.filter((u) => u.id !== user.id));
toast.success(t.toast.removed(user.name));
return ok(undefined);
}
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>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { checkAdminAction, findUniqueConflicts } from "./guards";
const admin = { id: "a1", role: "admin" as const };
const user = { id: "u1", role: "user" as const };
describe("checkAdminAction", () => {
it("admin kendini silemez, askıya alamaz, rolünü düşüremez", () => {
const ctx = { actorId: "a1", target: admin, adminCount: 3 };
expect(checkAdminAction({ type: "delete" }, ctx)).toBe("self");
expect(checkAdminAction({ type: "ban" }, ctx)).toBe("self");
expect(checkAdminAction({ type: "setRole", role: "user" }, ctx)).toBe("self");
});
it("admin kendi bilgisini ve şifresini değiştirebilir", () => {
const ctx = { actorId: "a1", target: admin, adminCount: 1 };
expect(checkAdminAction({ type: "edit" }, ctx)).toBeNull();
expect(checkAdminAction({ type: "setPassword" }, ctx)).toBeNull();
expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull();
});
it("son admin silinemez, askıya alınamaz, düşürülemez", () => {
const ctx = { actorId: "x", target: admin, adminCount: 1 };
expect(checkAdminAction({ type: "delete" }, ctx)).toBe("lastAdmin");
expect(checkAdminAction({ type: "ban" }, ctx)).toBe("lastAdmin");
expect(checkAdminAction({ type: "setRole", role: "user" }, ctx)).toBe("lastAdmin");
});
it("birden çok admin varken başka bir admin düşürülebilir", () => {
const ctx = { actorId: "x", target: admin, adminCount: 2 };
expect(checkAdminAction({ type: "setRole", role: "user" }, ctx)).toBeNull();
expect(checkAdminAction({ type: "delete" }, ctx)).toBeNull();
});
it("normal kullanıcıya her işlem yapılabilir", () => {
const ctx = { actorId: "a1", target: user, adminCount: 1 };
for (const t of ["delete", "ban", "edit", "setPassword"] as const) {
expect(checkAdminAction({ type: t }, ctx)).toBeNull();
}
expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull();
});
});
describe("findUniqueConflicts", () => {
const users = [
{ id: "1", username: "ayse", email: "ayse@x.com" },
{ id: "2", username: "emre" },
];
it("çakışan kullanıcı adı ve e-postayı bulur", () => {
expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" })).toEqual({
username: "taken",
email: "taken",
});
});
it("düzenlenen kaydın kendisini saymaz, boş e-postayı kontrol etmez", () => {
expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" }, "1")).toEqual({});
expect(findUniqueConflicts(users, { username: "yeni" })).toEqual({});
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { SystemRole } from "@/shared/schemas/common";
export type AdminAction =
| { type: "delete" }
| { type: "ban" }
| { type: "setRole"; role: SystemRole }
| { type: "edit" }
| { type: "setPassword" };
export type GuardContext = {
actorId: string;
target: { id: string; role: SystemRole };
/** Sistemdeki toplam admin sayısı (hedef dahil). */
adminCount: number;
};
export type GuardViolation = "self" | "lastAdmin";
/**
* Admin işlem kuralları (arayüz ve sunucu aynı fonksiyonu kullanır):
* - Admin kendini silemez, askıya alamaz ve kendi admin rolünü kaldıramaz.
* - Sistemdeki son admin silinemez, askıya alınamaz veya rolü düşürülemez.
*/
export function checkAdminAction(action: AdminAction, ctx: GuardContext): GuardViolation | null {
const isSelf = ctx.actorId === ctx.target.id;
const removesAdmin =
ctx.target.role === "admin" &&
(action.type === "delete" ||
action.type === "ban" ||
(action.type === "setRole" && action.role !== "admin"));
if (isSelf && (action.type === "delete" || action.type === "ban" || removesAdmin)) {
return "self";
}
if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin";
return null;
}
/** Kullanıcı adı / e-posta benzersizlik kontrolü (normalize edilmiş değerlerle). */
export function findUniqueConflicts(
users: ReadonlyArray<{ id: string; username: string; email?: string }>,
values: { username: string; email?: string },
excludeId?: string,
): { username?: "taken"; email?: "taken" } {
const others = users.filter((u) => u.id !== excludeId);
return {
...(others.some((u) => u.username === values.username) ? { username: "taken" as const } : {}),
...(values.email && others.some((u) => u.email === values.email)
? { email: "taken" as const }
: {}),
};
}
+111
View File
@@ -0,0 +1,111 @@
import type { AdminUser } from "./types";
/** FAZ 2 GEÇİCİ: Faz 3'te Better Auth admin API'sinden gelen listeyle değiştirilecek. */
export const MOCK_ADMIN_USERS: AdminUser[] = [
{
id: "u_admin",
name: "Suphi Çakır",
username: "suphi",
email: "admin@butcem.local",
role: "admin",
banned: false,
createdAt: "2026-05-02T08:00:00Z",
},
{
id: "u_ayse",
name: "Ayşe Çakır",
username: "ayse",
email: "ayse@butcem.local",
role: "user",
banned: false,
createdAt: "2026-05-02T08:10:00Z",
},
{
id: "u_emre",
name: "Emre Çakır",
username: "emre",
role: "user",
banned: false,
createdAt: "2026-05-03T17:45:00Z",
},
{
id: "u_zeynep",
name: "Zeynep Çakır",
username: "zeynep",
role: "user",
banned: false,
createdAt: "2026-05-03T17:50:00Z",
},
{
id: "u_fatma",
name: "Fatma Demir",
username: "fatma.demir",
email: "fatma@butcem.local",
role: "user",
banned: false,
createdAt: "2026-06-11T12:00:00Z",
},
{
id: "u_mehmet",
name: "Mehmet Demir",
username: "mehmet.demir",
email: "mehmet@butcem.local",
role: "user",
banned: false,
createdAt: "2026-06-11T12:05:00Z",
},
{
id: "u_ozan",
name: "Ozan Kaya",
username: "ozan",
email: "ozan@butcem.local",
role: "user",
banned: true,
banReason: "Eski ev arkadaşı",
createdAt: "2026-07-20T09:30:00Z",
},
{
id: "u_elif",
name: "Elif Şahin",
username: "elif_s",
email: "elif@butcem.local",
role: "user",
banned: false,
createdAt: "2026-08-14T19:15:00Z",
},
{
id: "u_burak",
name: "Burak Öztürk",
username: "burak",
email: "burak@butcem.local",
role: "admin",
banned: false,
createdAt: "2026-09-01T06:40:00Z",
},
{
id: "u_irem",
name: "İrem Güneş",
username: "irem",
role: "user",
banned: false,
createdAt: "2026-09-22T15:00:00Z",
},
{
id: "u_can",
name: "Can Yıldız",
username: "can.y",
email: "can@butcem.local",
role: "user",
banned: false,
createdAt: "2026-10-05T10:20:00Z",
},
{
id: "u_selin",
name: "Selin Aydın",
username: "selin",
email: "selin@butcem.local",
role: "user",
banned: false,
createdAt: "2026-10-30T21:05:00Z",
},
];
+14
View File
@@ -0,0 +1,14 @@
import type { SystemRole } from "@/shared/schemas/common";
/** Admin kullanıcı listesindeki satır (Better Auth user kaydının arayüz özeti). */
export type AdminUser = {
id: string;
name: string;
username: string;
email?: string;
role: SystemRole;
banned: boolean;
banReason?: string;
/** ISO tarih (UTC) */
createdAt: string;
};