feat(admin): Server Action'larla kullanıcı yönetimi ve gerçek oturumlu sayfalar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:02:00 +03:00
parent 5191a5cce1
commit 2035f73e7d
18 changed files with 437 additions and 270 deletions
+250
View File
@@ -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<never> {
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<Awaited<ReturnType<typeof adminContext>>>;
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<number> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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);
}
@@ -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<DialogState>(null);
// Arayüzdeki kural kontrolü yalnızca erken geri bildirim içindir; asıl kontrol sunucuda.
const adminCount = users.filter((u) => u.role === "admin").length;
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null =>
@@ -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<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,
});
/** Server Action'ı çağırır; başarıda bildirim gösterir. Sayfa revalidatePath ile tazelenir. */
async function run(promise: Promise<ActionResult>, success: string): Promise<ActionResult> {
try {
const result = await promise;
if (result.ok) toast.success(success);
return result;
} catch {
return fail(tr.common.genericError);
}
const { 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);
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<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 removeUser = (user: AdminUser) =>
run(removeUserAction(user.id), t.toast.removed(user.name));
const table = useTable({
features,
+1 -18
View File
@@ -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({});
});
});
-15
View File
@@ -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 }
: {}),
};
}
+27
View File
@@ -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(),
};
}
-111
View File
@@ -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",
},
];
+16
View File
@@ -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<AdminUser[]> {
await requireRole("admin");
const result = await getAuth().api.listUsers({
headers: await headers(),
query: { limit: 1000, sortBy: "createdAt", sortDirection: "desc" },
});
return result.users.map(toAdminUser);
}
-13
View File
@@ -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",
};