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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user