diff --git a/src/app/(app)/admin/kullanicilar/page.tsx b/src/app/(app)/admin/kullanicilar/page.tsx new file mode 100644 index 0000000..6875a05 --- /dev/null +++ b/src/app/(app)/admin/kullanicilar/page.tsx @@ -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 ( + <> + + + > + ); +} diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..7cc8092 --- /dev/null +++ b/src/app/(app)/admin/layout.tsx @@ -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; +} diff --git a/src/features/admin-users/components/user-action-dialogs.tsx b/src/features/admin-users/components/user-action-dialogs.tsx new file mode 100644 index 0000000..0e35b64 --- /dev/null +++ b/src/features/admin-users/components/user-action-dialogs.tsx @@ -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 = { + user: AdminUser | undefined; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (user: AdminUser, values: T) => Promise; +}; + +export function SetPasswordDialog({ + user, + open, + onOpenChange, + onConfirm, +}: DialogProps<{ password: string }>) { + const form = useForm({ + 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 ( + + onOpenChange(false)}> + {tr.common.cancel} + + + {form.formState.isSubmitting ? : null} + {t.password.confirm} + + > + } + > + + + ( + + {t.password.newPassword} + + {t.form.passwordHint} + {fieldState.invalid ? : null} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + + + + ); +} + +export function BanUserDialog({ + user, + open, + onOpenChange, + onConfirm, +}: DialogProps<{ reason?: string }>) { + const form = useForm({ + 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 ( + + onOpenChange(false)}> + {tr.common.cancel} + + + {form.formState.isSubmitting ? : null} + {t.ban.confirm} + + > + } + > + + + ( + + {t.ban.reason} + + {fieldState.invalid ? : null} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + + + + ); +} + +export function DeleteUserDialog({ user, open, onOpenChange, onConfirm }: DialogProps) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(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 ( + + + + {t.remove.title} + + {user ? t.remove.description(user.name) : null} + + + {error ? {error} : null} + + {tr.common.cancel} + {/* AlertDialogAction otomatik kapanır; sonuç beklenmesi için normal düğme. */} + + {pending ? : null} + {t.remove.confirm} + + + + + ); +} diff --git a/src/features/admin-users/components/user-form-dialog.tsx b/src/features/admin-users/components/user-form-dialog.tsx new file mode 100644 index 0000000..f23d400 --- /dev/null +++ b/src/features/admin-users/components/user-form-dialog.tsx @@ -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; + onUpdate: (id: string, values: UserUpdate) => Promise; +}; + +type FormOutput = UserCreate | UserUpdate; + +const createResolver: Resolver = + 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({ + 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 ( + + onOpenChange(false)}> + {tr.common.cancel} + + + {submitting ? : null} + {isEdit ? tr.common.save : tr.common.create} + + > + } + > + + + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.username} + + {t.usernameHint} + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.email} + + {t.emailHint} + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.role} + + + {tr.roles[field.value]} + + + {SYSTEM_ROLES.map((r) => ( + + {tr.roles[r]} + + ))} + + + {fieldState.invalid ? : null} + + )} + /> + {!isEdit ? ( + ( + + {t.password} + + {t.passwordHint} + {fieldState.invalid ? : null} + + )} + /> + ) : null} + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + + + + ); +} diff --git a/src/features/admin-users/components/users-table.tsx b/src/features/admin-users/components/users-table.tsx new file mode 100644 index 0000000..e0486a9 --- /dev/null +++ b/src/features/admin-users/components/users-table.tsx @@ -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 = (a, b, id) => + compareTr(String(a.getValue(id) ?? ""), String(b.getValue(id) ?? "")); + +const helper = createColumnHelper(); + +/** Mobilde gizlenen sütunlar. */ +const COLUMN_CLASS: Partial> = { + 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 ( + + {label} + + + ); +} + +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(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 ( + + + {user.name} + {user.id === currentUserId ? ( + ({t.you}) + ) : null} + + @{user.username} + + ); +} + +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. + + + + + + + + {user.name} + setDialog({ kind: "edit", user })}> + + {t.actions.edit} + + setRole(user, toggleRole)}> + {toggleRole === "admin" ? : } + {toggleRole === "admin" ? t.actions.makeAdmin : t.actions.makeUser} + + setDialog({ kind: "password", user })}> + + {t.actions.resetPassword} + + {user.banned ? ( + unbanUser(user)}> + + {t.actions.unban} + + ) : ( + setDialog({ kind: "ban", user })} + > + + {t.actions.ban} + + )} + + setDialog({ kind: "delete", user })} + > + + {t.actions.delete} + + {blocked ? ( + {guardMessage(blocked)} + ) : null} + + + ); +} + +const columns = helper.columns([ + helper.accessor("name", { + header: ({ column }) => ( + + ), + sortFn: byTr, + cell: ({ row }) => , + }), + helper.accessor("username", { + header: ({ column }) => ( + + ), + sortFn: byTr, + cell: ({ getValue }) => @{getValue()}, + }), + helper.accessor((u) => u.email ?? "", { + id: "email", + header: ({ column }) => ( + + ), + sortFn: byTr, + cell: ({ getValue }) => + getValue() || {t.noEmail}, + }), + helper.accessor("role", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + + {tr.roles[getValue()]} + + ), + }), + helper.accessor("banned", { + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.banned ? ( + + {t.status.banned} + + ) : ( + + {t.status.active} + + ), + }), + helper.accessor("createdAt", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => {formatDate(getValue())}, + }), + helper.display({ + id: "actions", + header: () => {tr.common.actions}, + cell: ({ row }) => , + }), +]); + +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(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 { + 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 { + 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 { + toast.success(t.toast.passwordSet(user.name)); + return ok(undefined); + } + + async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise { + 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 { + 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 ( + + + + + + + + table.setGlobalFilter(e.target.value)} + placeholder={t.searchPlaceholder} + aria-label={tr.common.search} + /> + + setDialog({ kind: "create" })}> + + {t.newUser} + + + + + + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => ( + + {header.isPlaceholder ? null : } + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + + + ))} + + )) + ) : ( + + + {tr.common.noResults} + + + )} + + + + + + {tr.common.totalRows(filteredCount)} + + {tr.common.rowsPerPage} + table.setPageSize(Number(v))} + > + + {table.state.pagination.pageSize} + + + {PAGE_SIZES.map((n) => ( + + {n} + + ))} + + + + {tr.common.page(table.state.pagination.pageIndex + 1, pageCount)} + + table.previousPage()} + disabled={!table.getCanPreviousPage()} + aria-label={tr.common.previous} + > + + + table.nextPage()} + disabled={!table.getCanNextPage()} + aria-label={tr.common.next} + > + + + + + + + + + + + + ); +} diff --git a/src/features/admin-users/guards.test.ts b/src/features/admin-users/guards.test.ts new file mode 100644 index 0000000..8df02f7 --- /dev/null +++ b/src/features/admin-users/guards.test.ts @@ -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({}); + }); +}); diff --git a/src/features/admin-users/guards.ts b/src/features/admin-users/guards.ts new file mode 100644 index 0000000..a3230e1 --- /dev/null +++ b/src/features/admin-users/guards.ts @@ -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 } + : {}), + }; +} diff --git a/src/features/admin-users/mock.ts b/src/features/admin-users/mock.ts new file mode 100644 index 0000000..724387a --- /dev/null +++ b/src/features/admin-users/mock.ts @@ -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", + }, +]; diff --git a/src/features/admin-users/types.ts b/src/features/admin-users/types.ts new file mode 100644 index 0000000..08d9d40 --- /dev/null +++ b/src/features/admin-users/types.ts @@ -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; +};
{guardMessage(blocked)}