+
+
+ {Array.from({ length: 8 }, (_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx
index 3987572..434a33f 100644
--- a/src/components/layout/user-menu.tsx
+++ b/src/components/layout/user-menu.tsx
@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { SessionUser } from "@/features/auth/types";
+import { authClient } from "@/lib/auth-client";
import { tr } from "@/i18n/tr";
export function initials(name: string) {
@@ -30,10 +31,15 @@ export function initials(name: string) {
export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
const router = useRouter();
- function logout() {
- // FAZ 3: authClient.signOut() sonrası yönlendirme.
+ async function logout() {
+ const { error } = await authClient.signOut();
+ if (error) {
+ toast.error(tr.common.genericError);
+ return;
+ }
toast.success(tr.userMenu.loggedOut);
router.replace("/login");
+ router.refresh();
}
return (
@@ -61,7 +67,7 @@ export function UserMenu({ user, version }: { user: SessionUser; version: string
-
+ void logout()}>
{tr.userMenu.logout}
diff --git a/src/features/admin-users/actions.ts b/src/features/admin-users/actions.ts
new file mode 100644
index 0000000..38e62df
--- /dev/null
+++ b/src/features/admin-users/actions.ts
@@ -0,0 +1,250 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { headers } from "next/headers";
+import { z } from "zod";
+import { tr } from "@/i18n/tr";
+import { fail, ok, type ActionResult } from "@/lib/action-result";
+import { getAuth } from "@/lib/auth";
+import { authErrorCode } from "@/lib/auth-errors";
+import { getActionUser } from "@/lib/dal";
+import { storedEmail } from "@/shared/account-email";
+import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common";
+import {
+ banUserSchema,
+ setPasswordSchema,
+ userCreateSchema,
+ userUpdateSchema,
+} from "@/shared/schemas/user";
+import { checkAdminAction, type AdminAction, type GuardViolation } from "./guards";
+
+/**
+ * Admin kullanıcı yönetimi Server Action'ları.
+ * Sıra: yetki (sunucuda) → zod doğrulama → iş kuralları (guards) → Better Auth admin API → revalidate.
+ */
+
+const t = tr.adminUsers;
+const PATH = "/admin/kullanicilar";
+const userIdSchema = z.string().min(1).max(64);
+
+const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin);
+
+/** Bilinen Better Auth hata kodlarını kullanıcıya gösterilecek alan hatalarına çevirir. */
+function mapAuthError(error: unknown): ActionResult {
+ const code = authErrorCode(error);
+ switch (code) {
+ case "USERNAME_IS_ALREADY_TAKEN":
+ return fail(t.errors.checkFields, { username: t.errors.usernameTaken });
+ case "USER_ALREADY_EXISTS":
+ case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
+ return fail(t.errors.checkFields, { email: t.errors.emailTaken });
+ case "INVALID_USERNAME":
+ case "USERNAME_TOO_SHORT":
+ case "USERNAME_TOO_LONG":
+ return fail(t.errors.checkFields, { username: tr.validation.usernameFormat });
+ case "INVALID_EMAIL":
+ return fail(t.errors.checkFields, { email: tr.validation.email });
+ case "PASSWORD_TOO_SHORT":
+ case "PASSWORD_TOO_LONG":
+ return fail(t.errors.checkFields, { password: tr.validation.passwordMin });
+ case "YOU_CANNOT_BAN_YOURSELF":
+ case "YOU_CANNOT_REMOVE_YOURSELF":
+ return fail(t.guards.self);
+ default:
+ // İç detay kullanıcıya sızdırılmaz; sunucuda loglanır.
+ console.error("[admin-users] beklenmeyen hata", code ?? error);
+ return fail(tr.common.genericError);
+ }
+}
+
+/** Fiziksel kontrol: oturumdaki kullanıcı admin mi? Değilse işlem yapılmaz. */
+async function adminContext() {
+ const actor = await getActionUser("admin");
+ if (!actor) return null;
+ return { actor, headers: await headers(), api: getAuth().api };
+}
+
+type Ctx = NonNullable>>;
+
+async function loadTarget(ctx: Ctx, userId: string) {
+ try {
+ const user = await ctx.api.getUser({ headers: ctx.headers, query: { id: userId } });
+ return user
+ ? {
+ id: user.id,
+ role: (user.role === "admin" ? "admin" : "user") as SystemRole,
+ name: user.name,
+ }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+async function countAdmins(ctx: Ctx): Promise {
+ const result = await ctx.api.listUsers({
+ headers: ctx.headers,
+ query: { filterField: "role", filterOperator: "eq", filterValue: "admin", limit: 1 },
+ });
+ return result.total;
+}
+
+async function guard(ctx: Ctx, action: AdminAction, target: { id: string; role: SystemRole }) {
+ const adminCount = target.role === "admin" ? await countAdmins(ctx) : Number.POSITIVE_INFINITY;
+ return checkAdminAction(action, { actorId: ctx.actor.id, target, adminCount });
+}
+
+// --- Action'lar ---
+
+export async function createUserAction(input: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const parsed = userCreateSchema.safeParse(input);
+ if (!parsed.success) return fail(t.errors.checkFields);
+ const v = parsed.data;
+ try {
+ await ctx.api.createUser({
+ headers: ctx.headers,
+ body: {
+ name: v.name,
+ email: storedEmail(v.username, v.email),
+ password: v.password,
+ role: v.role,
+ data: { username: v.username },
+ },
+ });
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
+
+export async function updateUserAction(userId: unknown, input: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ const parsed = userUpdateSchema.safeParse(input);
+ if (!id.success || !parsed.success) return fail(t.errors.checkFields);
+ const target = await loadTarget(ctx, id.data);
+ if (!target) return fail(tr.common.notFound);
+ const v = parsed.data;
+
+ if (v.role !== target.role) {
+ const violation = await guard(ctx, { type: "setRole", role: v.role }, target);
+ if (violation) return fail(guardMessage(violation), { role: guardMessage(violation) });
+ }
+ try {
+ await ctx.api.adminUpdateUser({
+ headers: ctx.headers,
+ body: {
+ userId: target.id,
+ data: { name: v.name, username: v.username, email: storedEmail(v.username, v.email) },
+ },
+ });
+ if (v.role !== target.role) {
+ await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: v.role } });
+ }
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
+
+export async function setRoleAction(userId: unknown, role: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ const r = systemRoleSchema.safeParse(role);
+ if (!id.success || !r.success) return fail(tr.common.genericError);
+ const target = await loadTarget(ctx, id.data);
+ if (!target) return fail(tr.common.notFound);
+ const violation = await guard(ctx, { type: "setRole", role: r.data }, target);
+ if (violation) return fail(guardMessage(violation));
+ try {
+ await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: r.data } });
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
+
+export async function setPasswordAction(userId: unknown, input: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ const parsed = setPasswordSchema.safeParse(input);
+ if (!id.success || !parsed.success) return fail(t.errors.checkFields);
+ const target = await loadTarget(ctx, id.data);
+ if (!target) return fail(tr.common.notFound);
+ try {
+ await ctx.api.setUserPassword({
+ headers: ctx.headers,
+ body: { userId: target.id, newPassword: parsed.data.password },
+ });
+ // Başka bir kullanıcının şifresi sıfırlandıysa açık oturumlarını kapat.
+ if (target.id !== ctx.actor.id) {
+ await ctx.api.revokeUserSessions({ headers: ctx.headers, body: { userId: target.id } });
+ }
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ return ok(undefined);
+}
+
+export async function banUserAction(userId: unknown, input: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ const parsed = banUserSchema.safeParse(input);
+ if (!id.success || !parsed.success) return fail(t.errors.checkFields);
+ const target = await loadTarget(ctx, id.data);
+ if (!target) return fail(tr.common.notFound);
+ const violation = await guard(ctx, { type: "ban" }, target);
+ if (violation) return fail(guardMessage(violation));
+ try {
+ // banUser açık oturumları da sonlandırır.
+ await ctx.api.banUser({
+ headers: ctx.headers,
+ body: { userId: target.id, banReason: parsed.data.reason },
+ });
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
+
+export async function unbanUserAction(userId: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ if (!id.success) return fail(tr.common.genericError);
+ try {
+ await ctx.api.unbanUser({ headers: ctx.headers, body: { userId: id.data } });
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
+
+export async function removeUserAction(userId: unknown): Promise {
+ const ctx = await adminContext();
+ if (!ctx) return fail(tr.common.unauthorized);
+ const id = userIdSchema.safeParse(userId);
+ if (!id.success) return fail(tr.common.genericError);
+ const target = await loadTarget(ctx, id.data);
+ if (!target) return fail(tr.common.notFound);
+ const violation = await guard(ctx, { type: "delete" }, target);
+ if (violation) return fail(guardMessage(violation));
+ try {
+ await ctx.api.removeUser({ headers: ctx.headers, body: { userId: target.id } });
+ } catch (error) {
+ return mapAuthError(error);
+ }
+ revalidatePath(PATH);
+ return ok(undefined);
+}
diff --git a/src/features/admin-users/components/users-table.tsx b/src/features/admin-users/components/users-table.tsx
index e0486a9..388bbf2 100644
--- a/src/features/admin-users/components/users-table.tsx
+++ b/src/features/admin-users/components/users-table.tsx
@@ -59,18 +59,22 @@ import {
TableRow,
} from "@/components/ui/table";
import { tr } from "@/i18n/tr";
-import { fail, ok, type ActionResult } from "@/lib/action-result";
+import { fail, type ActionResult } from "@/lib/action-result";
import { cn } from "@/lib/utils";
import { formatDate } from "@/shared/format";
import type { SystemRole } from "@/shared/schemas/common";
import type { UserCreate, UserUpdate } from "@/shared/schemas/user";
import { compareTr, includesTr } from "@/shared/search";
import {
- checkAdminAction,
- findUniqueConflicts,
- type AdminAction,
- type GuardViolation,
-} from "../guards";
+ banUserAction,
+ createUserAction,
+ removeUserAction,
+ setPasswordAction,
+ setRoleAction,
+ unbanUserAction,
+ updateUserAction,
+} from "../actions";
+import { checkAdminAction, type AdminAction, type GuardViolation } from "../guards";
import type { AdminUser } from "../types";
import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs";
import { UserFormDialog } from "./user-form-dialog";
@@ -311,15 +315,14 @@ const columns = helper.columns([
]);
export function UsersTable({
- initialUsers,
+ users,
currentUserId,
}: {
- initialUsers: AdminUser[];
+ users: AdminUser[];
currentUserId: string;
}) {
- // FAZ 3: yerel durum yerine Server Action + revalidatePath.
- const [users, setUsers] = useState(initialUsers);
const [dialog, setDialog] = useState(null);
+ // Arayüzdeki kural kontrolü yalnızca erken geri bildirim içindir; asıl kontrol sunucuda.
const adminCount = users.filter((u) => u.role === "admin").length;
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null =>
@@ -327,84 +330,41 @@ export function UsersTable({
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,
- });
+ /** Server Action'ı çağırır; başarıda bildirim gösterir. Sayfa revalidatePath ile tazelenir. */
+ async function run(promise: Promise, success: string): Promise {
+ try {
+ const result = await promise;
+ if (result.ok) toast.success(success);
+ return result;
+ } catch {
+ return fail(tr.common.genericError);
}
- 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);
+ const createUser = (values: UserCreate) =>
+ run(createUserAction(values), t.toast.created(values.name));
+
+ const updateUser = (id: string, values: UserUpdate) =>
+ run(updateUserAction(id, values), t.toast.updated(values.name));
+
+ async function setRole(user: AdminUser, role: SystemRole) {
+ const result = await run(setRoleAction(user.id, role), t.toast.roleChanged(user.name));
+ if (!result.ok) toast.error(result.error);
}
- 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));
+ const setPassword = (user: AdminUser, values: { password: string }) =>
+ run(setPasswordAction(user.id, values), t.toast.passwordSet(user.name));
+
+ const banUser = (user: AdminUser, values: { reason?: string }) =>
+ run(banUserAction(user.id, values), t.toast.banned(user.name));
+
+ async function unbanUser(user: AdminUser) {
+ const result = await run(unbanUserAction(user.id), t.toast.unbanned(user.name));
+ if (!result.ok) toast.error(result.error);
}
- 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 removeUser = (user: AdminUser) =>
+ run(removeUserAction(user.id), t.toast.removed(user.name));
const table = useTable({
features,
diff --git a/src/features/admin-users/guards.test.ts b/src/features/admin-users/guards.test.ts
index 8df02f7..63108e2 100644
--- a/src/features/admin-users/guards.test.ts
+++ b/src/features/admin-users/guards.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { checkAdminAction, findUniqueConflicts } from "./guards";
+import { checkAdminAction } from "./guards";
const admin = { id: "a1", role: "admin" as const };
const user = { id: "u1", role: "user" as const };
@@ -40,20 +40,3 @@ describe("checkAdminAction", () => {
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
index a3230e1..e1fbde2 100644
--- a/src/features/admin-users/guards.ts
+++ b/src/features/admin-users/guards.ts
@@ -35,18 +35,3 @@ export function checkAdminAction(action: AdminAction, ctx: GuardContext): GuardV
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/mapper.ts b/src/features/admin-users/mapper.ts
new file mode 100644
index 0000000..b836c8f
--- /dev/null
+++ b/src/features/admin-users/mapper.ts
@@ -0,0 +1,27 @@
+import { visibleEmail } from "@/shared/account-email";
+import type { AdminUser } from "./types";
+
+type RawAdminUser = {
+ id: string;
+ name: string;
+ email: string;
+ username?: string | null;
+ role?: string | null;
+ banned?: boolean | null;
+ banReason?: string | null;
+ createdAt: Date | string;
+};
+
+/** Better Auth kullanıcı kaydını arayüz satırına çevirir (yer tutucu e-posta gizlenir). */
+export function toAdminUser(u: RawAdminUser): AdminUser {
+ return {
+ id: u.id,
+ name: u.name,
+ username: u.username ?? "",
+ email: visibleEmail(u.email),
+ role: u.role === "admin" ? "admin" : "user",
+ banned: Boolean(u.banned),
+ banReason: u.banReason ?? undefined,
+ createdAt: new Date(u.createdAt).toISOString(),
+ };
+}
diff --git a/src/features/admin-users/mock.ts b/src/features/admin-users/mock.ts
deleted file mode 100644
index 724387a..0000000
--- a/src/features/admin-users/mock.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-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/queries.ts b/src/features/admin-users/queries.ts
new file mode 100644
index 0000000..f043a2f
--- /dev/null
+++ b/src/features/admin-users/queries.ts
@@ -0,0 +1,16 @@
+import "server-only";
+import { headers } from "next/headers";
+import { getAuth } from "@/lib/auth";
+import { requireRole } from "@/lib/dal";
+import { toAdminUser } from "./mapper";
+import type { AdminUser } from "./types";
+
+/** Admin: tüm kullanıcılar (en yeni önce). Kişisel kullanım ölçeğinde tek sayfa yeterli. */
+export async function listAdminUsers(): Promise {
+ await requireRole("admin");
+ const result = await getAuth().api.listUsers({
+ headers: await headers(),
+ query: { limit: 1000, sortBy: "createdAt", sortDirection: "desc" },
+ });
+ return result.users.map(toAdminUser);
+}
diff --git a/src/features/auth/mock.ts b/src/features/auth/mock.ts
deleted file mode 100644
index 07d770d..0000000
--- a/src/features/auth/mock.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { SessionUser } from "./types";
-
-/**
- * FAZ 2 GEÇİCİ: Better Auth bağlanana kadar arayüzü besleyen sahte oturum.
- * Faz 3'te `requireSession()` (lib/dal.ts) ile değiştirilecek ve bu dosya silinecek.
- */
-export const MOCK_SESSION_USER: SessionUser = {
- id: "u_admin",
- name: "Suphi Çakır",
- username: "suphi",
- email: "admin@butcem.local",
- role: "admin",
-};
diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts
index 9b583a2..e76d8ee 100644
--- a/src/i18n/tr.ts
+++ b/src/i18n/tr.ts
@@ -31,6 +31,8 @@ export const tr = {
showPassword: "Şifreyi göster",
hidePassword: "Şifreyi gizle",
genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
+ unauthorized: "Bu işlem için yetkiniz yok.",
+ notFound: "Kayıt bulunamadı.",
},
theme: {
label: "Tema",
@@ -90,6 +92,8 @@ export const tr = {
submit: "Giriş yap",
submitting: "Giriş yapılıyor…",
invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.",
+ banned: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.",
+ tooManyAttempts: "Çok fazla deneme yapıldı. Lütfen bir dakika sonra tekrar deneyin.",
noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.",
identifierRequired: "Kullanıcı adı veya e-posta girin",
passwordRequired: "Şifre girin",
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..cf80318
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -0,0 +1,9 @@
+"use client";
+
+import { adminClient, usernameClient } from "better-auth/client/plugins";
+import { createAuthClient } from "better-auth/react";
+
+/** Tarayıcı tarafı Better Auth istemcisi (aynı origin: /api/auth). */
+export const authClient = createAuthClient({
+ plugins: [usernameClient(), adminClient()],
+});
diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts
new file mode 100644
index 0000000..8989af7
--- /dev/null
+++ b/src/lib/auth-errors.ts
@@ -0,0 +1,8 @@
+import { isAPIError } from "better-auth/api";
+
+/** Better Auth hatasından makine okunur kodu çıkarır (ör. "USERNAME_IS_ALREADY_TAKEN"). */
+export function authErrorCode(error: unknown): string | undefined {
+ if (!isAPIError(error)) return undefined;
+ const body = error.body as { code?: unknown } | undefined;
+ return typeof body?.code === "string" ? body.code : undefined;
+}
diff --git a/src/lib/auth.int.test.ts b/src/lib/auth.int.test.ts
new file mode 100644
index 0000000..e3a7ba0
--- /dev/null
+++ b/src/lib/auth.int.test.ts
@@ -0,0 +1,191 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { getAuth } from "@/lib/auth";
+import { authErrorCode } from "@/lib/auth-errors";
+import { getDb, getMongoClient } from "@/lib/db/mongo";
+import { placeholderEmail } from "@/shared/account-email";
+
+/**
+ * Better Auth yapılandırmasının güvenlik sözleşmesi (gerçek MongoDB, butcem_test veritabanı).
+ */
+
+const auth = getAuth();
+const PASSWORD = "Gizli-Sifre-123";
+
+/** Giriş yapıp oturum cookie'sini içeren istek başlıklarını döner. */
+async function signIn(username: string, password = PASSWORD): Promise {
+ const { headers } = await auth.api.signInUsername({
+ body: { username, password },
+ returnHeaders: true,
+ });
+ const cookie = headers
+ .getSetCookie()
+ .map((c) => c.split(";")[0])
+ .join("; ");
+ return new Headers({ cookie });
+}
+
+async function expectAuthError(promise: Promise, code: string) {
+ const error = await promise.then(
+ () => null,
+ (e: unknown) => e,
+ );
+ expect(error, `beklenen hata: ${code}`).not.toBeNull();
+ expect(authErrorCode(error)).toBe(code);
+}
+
+beforeAll(async () => {
+ const db = getDb();
+ expect(db.databaseName).toBe("butcem_test");
+ await db.dropDatabase();
+ await auth.api.createUser({
+ body: {
+ email: "admin@test.local",
+ password: PASSWORD,
+ name: "Admin",
+ role: "admin",
+ data: { username: "admin" },
+ },
+ });
+ await auth.api.createUser({
+ body: {
+ email: placeholderEmail("emre"),
+ password: PASSWORD,
+ name: "Emre",
+ role: "user",
+ data: { username: "Emre" },
+ },
+ });
+});
+
+afterAll(async () => {
+ await getDb().dropDatabase();
+ await getMongoClient().close();
+});
+
+describe("kayıt kapalı", () => {
+ it("sign-up/email uç noktası reddedilir ve kullanıcı oluşmaz", async () => {
+ const res = await auth.handler(
+ new Request("http://localhost:3000/api/auth/sign-up/email", {
+ method: "POST",
+ headers: { "content-type": "application/json", origin: "http://localhost:3000" },
+ body: JSON.stringify({ email: "yeni@test.local", password: PASSWORD, name: "Yeni" }),
+ }),
+ );
+ expect(res.ok).toBe(false);
+ expect(await getDb().collection("user").countDocuments({ email: "yeni@test.local" })).toBe(0);
+ });
+});
+
+describe("giriş", () => {
+ it("kullanıcı adı küçük harfe normalize edilir ve kullanıcı adıyla giriş yapılır", async () => {
+ const stored = await getDb()
+ .collection("user")
+ .findOne({ email: placeholderEmail("emre") });
+ expect(stored?.username).toBe("emre");
+ const headers = await signIn("EMRE");
+ const session = await auth.api.getSession({ headers });
+ expect(session?.user.name).toBe("Emre");
+ expect(session?.user.role).toBe("user");
+ });
+
+ it("HTTP üzerinden büyük harfli (I içeren) kullanıcı adıyla giriş yapılır", async () => {
+ const res = await auth.handler(
+ new Request("http://localhost:3000/api/auth/sign-in/username", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: "http://localhost:3000",
+ "x-forwarded-for": "198.51.100.1",
+ },
+ // "I" içeren büyük harfli giriş (tr-TR'de "ADMIN" → "admın" tuzağı)
+ body: JSON.stringify({ username: "ADMIN", password: PASSWORD }),
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.getSetCookie().some((c) => c.includes("session_token"))).toBe(true);
+ });
+
+ it("e-posta ile giriş yapılır", async () => {
+ const result = await auth.api.signInEmail({
+ body: { email: "admin@test.local", password: PASSWORD },
+ });
+ expect(result.user.email).toBe("admin@test.local");
+ });
+
+ it("yanlış şifre reddedilir", async () => {
+ await expectAuthError(
+ auth.api.signInUsername({ body: { username: "emre", password: "yanlis-sifre" } }),
+ "INVALID_USERNAME_OR_PASSWORD",
+ );
+ });
+
+ it("aynı kullanıcı adı ikinci kez kullanılamaz", async () => {
+ await expectAuthError(
+ auth.api.createUser({
+ body: {
+ email: "baska@test.local",
+ password: PASSWORD,
+ name: "Kopya",
+ data: { username: "emre" },
+ },
+ }),
+ "USERNAME_IS_ALREADY_TAKEN",
+ );
+ });
+});
+
+describe("admin yetkileri", () => {
+ it("admin olmayan kullanıcı admin API'sini çağıramaz", async () => {
+ const headers = await signIn("emre");
+ const res = await auth.api
+ .listUsers({ headers, query: { limit: 10 } })
+ .then(() => "ok")
+ .catch((e: unknown) => authErrorCode(e) ?? "error");
+ expect(res).not.toBe("ok");
+ });
+
+ it("askıya alınan kullanıcı giriş yapamaz, askı kaldırılınca yapabilir", async () => {
+ const adminHeaders = await signIn("admin");
+ const emre = await getDb().collection("user").findOne({ username: "emre" });
+ const userId = String(emre?._id);
+
+ await auth.api.banUser({ headers: adminHeaders, body: { userId, banReason: "test" } });
+ await expectAuthError(
+ auth.api.signInUsername({ body: { username: "emre", password: PASSWORD } }),
+ "BANNED_USER",
+ );
+
+ await auth.api.unbanUser({ headers: adminHeaders, body: { userId } });
+ await expect(signIn("emre")).resolves.toBeInstanceOf(Headers);
+ });
+
+ it("admin kendini askıya alamaz", async () => {
+ const adminHeaders = await signIn("admin");
+ const self = await auth.api.getSession({ headers: adminHeaders });
+ await expectAuthError(
+ auth.api.banUser({ headers: adminHeaders, body: { userId: self!.user.id } }),
+ "YOU_CANNOT_BAN_YOURSELF",
+ );
+ });
+});
+
+describe("rate limit", () => {
+ it("giriş denemeleri dakikada 5 ile sınırlıdır", async () => {
+ const attempt = () =>
+ auth.handler(
+ new Request("http://localhost:3000/api/auth/sign-in/username", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: "http://localhost:3000",
+ "x-forwarded-for": "203.0.113.7",
+ },
+ body: JSON.stringify({ username: "emre", password: "yanlis-sifre" }),
+ }),
+ );
+ const statuses: number[] = [];
+ for (let i = 0; i < 6; i++) statuses.push((await attempt()).status);
+ expect(statuses.slice(0, 5).every((s) => s !== 429)).toBe(true);
+ expect(statuses[5]).toBe(429);
+ });
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..d53d67f
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,81 @@
+import "server-only";
+import { betterAuth } from "better-auth";
+import { mongodbAdapter } from "better-auth/adapters/mongodb";
+import { nextCookies } from "better-auth/next-js";
+import { admin, username } from "better-auth/plugins";
+import { getMongoClient } from "@/lib/db/mongo";
+import { env } from "@/lib/env";
+import { normalizeUsername, USERNAME_PATTERN } from "@/shared/schemas/common";
+
+function createAuth() {
+ const e = env();
+ const client = getMongoClient();
+ const isProd = e.NODE_ENV === "production";
+
+ return betterAuth({
+ appName: "BÜTÇEM",
+ baseURL: e.BETTER_AUTH_URL,
+ secret: e.BETTER_AUTH_SECRET,
+ // client verildiği için çok adımlı auth yazmaları transaction içinde yapılır (replica set).
+ database: mongodbAdapter(client.db(), { client }),
+ emailAndPassword: {
+ enabled: true,
+ // Kullanıcılar kendi hesabını açamaz; hesaplar yalnızca admin panelinden oluşturulur.
+ disableSignUp: true,
+ minPasswordLength: 8,
+ maxPasswordLength: 128,
+ revokeSessionsOnPasswordReset: true,
+ },
+ session: {
+ expiresIn: 60 * 60 * 24 * 30, // 30 gün
+ updateAge: 60 * 60 * 24, // günde bir uzat
+ },
+ rateLimit: {
+ enabled: true,
+ // Yeniden başlatmalarda sıfırlanmasın; tek örnek çalıştığı için yeterli.
+ storage: "database",
+ window: 60,
+ max: 100,
+ customRules: {
+ "/sign-in/email": { window: 60, max: 5 },
+ "/sign-in/username": { window: 60, max: 5 },
+ },
+ },
+ advanced: {
+ useSecureCookies: isProd,
+ cookiePrefix: "butcem",
+ // Rate limit istemci IP'sini X-Forwarded-For'dan okur. Canlıda Traefik güvenilmeyen
+ // istemcilerden gelen X-Forwarded-* başlıklarını silip kendisi yazmalı
+ // (forwardedHeaders.insecure=false, varsayılan) — docs/DEPLOY.md (Faz 15).
+ },
+ telemetry: { enabled: false },
+ plugins: [
+ username({
+ minUsernameLength: 3,
+ maxUsernameLength: 30,
+ // shared/schemas/common.ts ile aynı kural (İ/I/ı → i, ASCII küçük harf).
+ usernameNormalization: normalizeUsername,
+ // Giriş uç noktası normalizasyondan ÖNCE doğrular; büyük harfli girişler ("ADMIN")
+ // reddedilmesin diye doğrulama her zaman normalize edilmiş değer üzerinde yapılır.
+ usernameValidator: (value) => USERNAME_PATTERN.test(normalizeUsername(value)),
+ validationOrder: { username: "post-normalization" },
+ }),
+ admin({
+ defaultRole: "user",
+ adminRoles: ["admin"],
+ bannedUserMessage: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.",
+ }),
+ nextCookies(), // Server Action'larda cookie ayarlamak için en sonda olmalı
+ ],
+ });
+}
+
+export type Auth = ReturnType;
+
+const globalForAuth = globalThis as unknown as { __butcemAuth?: Auth };
+
+/** Better Auth örneği (tembel: build sırasında ortam değişkeni gerektirmez). */
+export function getAuth(): Auth {
+ globalForAuth.__butcemAuth ??= createAuth();
+ return globalForAuth.__butcemAuth;
+}
diff --git a/src/lib/dal.ts b/src/lib/dal.ts
new file mode 100644
index 0000000..f648cc4
--- /dev/null
+++ b/src/lib/dal.ts
@@ -0,0 +1,68 @@
+import "server-only";
+import { headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
+import { cache } from "react";
+import type { SessionUser } from "@/features/auth/types";
+import { getAuth } from "@/lib/auth";
+import { visibleEmail } from "@/shared/account-email";
+import type { SystemRole } from "@/shared/schemas/common";
+
+/**
+ * Data Access Layer — her korumalı sayfa, layout, Server Action ve Route Handler oturumu
+ * BURADAN okur. proxy.ts yalnızca iyimser (cookie var mı?) kontrol yapar; güvenlik bu katmandadır.
+ */
+
+type RawUser = {
+ id: string;
+ name: string;
+ email: string;
+ username?: string | null;
+ role?: string | null;
+ banned?: boolean | null;
+ image?: string | null;
+};
+
+export function toSessionUser(user: RawUser): SessionUser {
+ return {
+ id: user.id,
+ name: user.name,
+ username: user.username ?? "",
+ email: visibleEmail(user.email),
+ role: user.role === "admin" ? "admin" : "user",
+ image: user.image ?? undefined,
+ };
+}
+
+/** İstek başına bir kez çalışır (React cache). Oturum yoksa veya kullanıcı askıdaysa null. */
+export const getCurrentUser = cache(async (): Promise => {
+ // headers() önce çağrılır: Cache Components prerender'ı burada durur, auth hiç oluşturulmaz.
+ const requestHeaders = await headers();
+ const session = await getAuth().api.getSession({ headers: requestHeaders });
+ if (!session || session.user.banned) return null;
+ return toSessionUser(session.user);
+});
+
+/** Sayfa/layout: oturum yoksa /login'e yönlendirir. */
+export async function requireSession(): Promise {
+ const user = await getCurrentUser();
+ if (!user) redirect("/login");
+ return user;
+}
+
+/** Sayfa/layout: rol yoksa 404 (sayfanın varlığı bile gösterilmez). */
+export async function requireRole(role: SystemRole): Promise {
+ const user = await requireSession();
+ if (user.role !== role) notFound();
+ return user;
+}
+
+/**
+ * Server Action / Route Handler: yönlendirme yapmaz, yetkisizse null döner
+ * (çağıran `fail(...)` veya 401/403 döndürür).
+ */
+export async function getActionUser(role?: SystemRole): Promise {
+ const user = await getCurrentUser();
+ if (!user) return null;
+ if (role && user.role !== role) return null;
+ return user;
+}
diff --git a/src/lib/db/.gitkeep b/src/lib/db/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/lib/db/mongo.ts b/src/lib/db/mongo.ts
new file mode 100644
index 0000000..20f8d5f
--- /dev/null
+++ b/src/lib/db/mongo.ts
@@ -0,0 +1,36 @@
+import "server-only";
+import { MongoClient, type Db } from "mongodb";
+import { env } from "@/lib/env";
+
+/**
+ * Uygulama genelinde tek MongoClient. Geliştirmede HMR yeniden yüklemelerinde bağlantı
+ * çoğalmasın diye globalThis üzerinde saklanır. İstemci tembel oluşturulur: `next build`
+ * sırasında ortam değişkeni veya bağlantı gerektirmez (sürücü ilk işlemde bağlanır).
+ */
+const globalForMongo = globalThis as unknown as { __butcemMongo?: MongoClient };
+
+export function getMongoClient(): MongoClient {
+ if (!globalForMongo.__butcemMongo) {
+ globalForMongo.__butcemMongo = new MongoClient(env().MONGODB_URI, {
+ appName: "butcem",
+ maxPoolSize: 20,
+ serverSelectionTimeoutMS: 5_000,
+ });
+ }
+ return globalForMongo.__butcemMongo;
+}
+
+/** Bağlantı URI'sindeki veritabanı (ör. /butcem). */
+export function getDb(): Db {
+ return getMongoClient().db();
+}
+
+/** Sağlık kontrolü için hızlı ping. */
+export async function pingDb(timeoutMs = 2_000): Promise {
+ try {
+ await getDb().command({ ping: 1 }, { timeoutMS: timeoutMs });
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/db/mongoose.ts b/src/lib/db/mongoose.ts
new file mode 100644
index 0000000..b36cc51
--- /dev/null
+++ b/src/lib/db/mongoose.ts
@@ -0,0 +1,19 @@
+import "server-only";
+import mongoose from "mongoose";
+import { env } from "@/lib/env";
+
+/**
+ * Alan (domain) modelleri için Mongoose bağlantısı; MongoClient ile aynı önbellekleme deseni.
+ * Kimlik doğrulama koleksiyonları Better Auth tarafından native sürücüyle yönetilir.
+ */
+const globalForMongoose = globalThis as unknown as { __butcemMongoose?: Promise };
+
+export function connectMongoose(): Promise {
+ globalForMongoose.__butcemMongoose ??= mongoose.connect(env().MONGODB_URI, {
+ appName: "butcem-odm",
+ maxPoolSize: 20,
+ serverSelectionTimeoutMS: 5_000,
+ autoIndex: env().NODE_ENV !== "production",
+ });
+ return globalForMongoose.__butcemMongoose;
+}
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..a292ad9
--- /dev/null
+++ b/src/proxy.ts
@@ -0,0 +1,22 @@
+import { getSessionCookie } from "better-auth/cookies";
+import { NextResponse, type NextRequest } from "next/server";
+
+/**
+ * İYİMSER kontrol: yalnızca oturum cookie'si var mı diye bakar (veritabanına gitmez).
+ * Cookie yoksa korumalı sayfaları /login'e yönlendirir. Asıl yetki kontrolü lib/dal.ts'de;
+ * bu katman tek başına güvenlik sağlamaz.
+ */
+export function proxy(request: NextRequest) {
+ const hasSessionCookie = Boolean(getSessionCookie(request, { cookiePrefix: "butcem" }));
+ if (!hasSessionCookie) {
+ return NextResponse.redirect(new URL("/login", request.url));
+ }
+ return NextResponse.next();
+}
+
+export const config = {
+ // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest.
+ matcher: [
+ "/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)",
+ ],
+};
diff --git a/src/shared/account-email.test.ts b/src/shared/account-email.test.ts
new file mode 100644
index 0000000..f4a4a65
--- /dev/null
+++ b/src/shared/account-email.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { isPlaceholderEmail, placeholderEmail, storedEmail, visibleEmail } from "./account-email";
+
+describe("yer tutucu e-posta", () => {
+ it("kullanıcı adından .invalid adres üretir ve tanır", () => {
+ const e = placeholderEmail("emre");
+ expect(e).toBe("emre@kullanici.butcem.invalid");
+ expect(isPlaceholderEmail(e)).toBe(true);
+ expect(isPlaceholderEmail("EMRE@KULLANICI.BUTCEM.INVALID")).toBe(true);
+ expect(isPlaceholderEmail("emre@ornek.com")).toBe(false);
+ expect(isPlaceholderEmail(undefined)).toBe(false);
+ });
+
+ it("kaydedilen ve gösterilen e-posta", () => {
+ expect(storedEmail("emre", undefined)).toBe("emre@kullanici.butcem.invalid");
+ expect(storedEmail("ayse", "ayse@ornek.com")).toBe("ayse@ornek.com");
+ expect(visibleEmail("emre@kullanici.butcem.invalid")).toBeUndefined();
+ expect(visibleEmail("ayse@ornek.com")).toBe("ayse@ornek.com");
+ });
+});
diff --git a/src/shared/account-email.ts b/src/shared/account-email.ts
new file mode 100644
index 0000000..47a7b8b
--- /dev/null
+++ b/src/shared/account-email.ts
@@ -0,0 +1,24 @@
+/**
+ * E-postası olmayan kullanıcılar (ör. çocuklar) kullanıcı adıyla giriş yapar. Better Auth
+ * e-postayı zorunlu tuttuğu için bunlara ayrılmış `.invalid` alan adında (RFC 2606)
+ * yer tutucu bir adres verilir; arayüzde "e-posta yok" olarak gösterilir.
+ */
+export const PLACEHOLDER_EMAIL_DOMAIN = "kullanici.butcem.invalid";
+
+export function placeholderEmail(username: string): string {
+ return `${username}@${PLACEHOLDER_EMAIL_DOMAIN}`;
+}
+
+export function isPlaceholderEmail(email: string | null | undefined): boolean {
+ return Boolean(email?.toLowerCase().endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`));
+}
+
+/** Kaydedilecek e-posta: girilmişse o, yoksa kullanıcı adından yer tutucu. */
+export function storedEmail(username: string, email: string | undefined): string {
+ return email ?? placeholderEmail(username);
+}
+
+/** Gösterilecek e-posta: yer tutucuysa undefined. */
+export function visibleEmail(email: string | null | undefined): string | undefined {
+ return email && !isPlaceholderEmail(email) ? email : undefined;
+}
diff --git a/src/shared/schemas/common.ts b/src/shared/schemas/common.ts
index 6b90e49..377dd4e 100644
--- a/src/shared/schemas/common.ts
+++ b/src/shared/schemas/common.ts
@@ -12,11 +12,17 @@ export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/;
export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax);
-/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */
+/**
+ * Kullanıcı adı normalizasyonu (ASCII). Türkçe yerel ayarı KULLANILMAZ: tr-TR'de "I" → "ı" olur
+ * ve "ADMIN" "admın"e dönüşür. Bunun yerine İ/I/ı hepsi "i" yapılır, sonra küçük harfe çevrilir.
+ */
+export function normalizeUsername(value: string): string {
+ return value.trim().replace(/[İIı]/g, "i").toLowerCase();
+}
+
export const usernameSchema = z
.string()
- .trim()
- .transform((s) => s.toLocaleLowerCase("tr-TR"))
+ .transform(normalizeUsername)
.pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat));
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email));
diff --git a/src/shared/schemas/user.test.ts b/src/shared/schemas/user.test.ts
index a1d2630..d5c6f61 100644
--- a/src/shared/schemas/user.test.ts
+++ b/src/shared/schemas/user.test.ts
@@ -12,11 +12,18 @@ const base = {
};
describe("usernameSchema", () => {
- it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => {
+ it("küçük harfe çevirir ve boşlukları kırpar", () => {
expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k");
expect(usernameSchema.parse("Ali_2")).toBe("ali_2");
});
+ it("I/İ/ı harflerini i yapar (tr-TR'deki I → ı tuzağı)", () => {
+ expect(usernameSchema.parse("ADMIN")).toBe("admin");
+ expect(usernameSchema.parse("ALI")).toBe("ali");
+ expect(usernameSchema.parse("İREM")).toBe("irem");
+ expect(usernameSchema.parse("ırmak")).toBe("irmak");
+ });
+
it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => {
for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) {
expect(usernameSchema.safeParse(bad).success, bad).toBe(false);
diff --git a/src/test/server-only-stub.ts b/src/test/server-only-stub.ts
new file mode 100644
index 0000000..b159a7a
--- /dev/null
+++ b/src/test/server-only-stub.ts
@@ -0,0 +1,2 @@
+// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır).
+export {};
diff --git a/vitest.config.mts b/vitest.config.mts
index c50fe37..5f08968 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -3,12 +3,15 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
- alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)),
+ },
},
test: {
environment: "node",
include: ["src/**/*.test.ts", "scripts/**/*.test.ts"],
- // "server-only" paketi test ortamında hata fırlatmasın.
- server: { deps: { inline: ["server-only"] } },
+ // Veritabanı gerektiren testler ayrı: npm run test:integration
+ exclude: ["**/*.int.test.ts", "node_modules/**"],
},
});
diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts
new file mode 100644
index 0000000..6b388c5
--- /dev/null
+++ b/vitest.integration.config.mts
@@ -0,0 +1,37 @@
+import { fileURLToPath } from "node:url";
+import { loadEnv } from "vite";
+import { defineConfig } from "vitest/config";
+
+/**
+ * Entegrasyon testleri: gerçek MongoDB (npm run db:up) üzerinde, ayrı "butcem_test" veritabanı.
+ * Çalıştırma: npm run test:integration
+ */
+const fileEnv = loadEnv("test", process.cwd(), "");
+const local = { ...loadEnv("development", process.cwd(), ""), ...fileEnv };
+const baseUri =
+ process.env.MONGODB_URI_TEST ??
+ (
+ local.MONGODB_URI ?? "mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true"
+ ).replace(/\/([^/?]+)(\?|$)/, "/butcem_test$2");
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["src/**/*.int.test.ts"],
+ fileParallelism: false,
+ testTimeout: 20_000,
+ hookTimeout: 30_000,
+ env: {
+ NODE_ENV: "test",
+ MONGODB_URI: baseUri,
+ BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ },
+ },
+});