3644e081b1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
"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 { writeAudit } from "@/lib/audit";
|
||
import { getActionUser } from "@/lib/dal";
|
||
import { removeAllMembershipsOfUser } from "@/features/households/service";
|
||
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;
|
||
let createdId = "";
|
||
try {
|
||
const created = 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 },
|
||
},
|
||
});
|
||
createdId = created.user.id;
|
||
} catch (error) {
|
||
return mapAuthError(error);
|
||
}
|
||
await writeAudit({
|
||
action: "user.create",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: createdId,
|
||
diff: { username: v.username, role: v.role },
|
||
});
|
||
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);
|
||
}
|
||
await writeAudit({
|
||
action: "user.update",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: target.id,
|
||
diff: { name: v.name, username: v.username, role: v.role },
|
||
});
|
||
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);
|
||
}
|
||
await writeAudit({
|
||
action: "user.role",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: target.id,
|
||
diff: { role: { from: target.role, to: r.data } },
|
||
});
|
||
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);
|
||
}
|
||
await writeAudit({
|
||
action: "user.password.reset",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: target.id,
|
||
});
|
||
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);
|
||
}
|
||
await writeAudit({
|
||
action: "user.ban",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: target.id,
|
||
diff: { reason: parsed.data.reason ?? null },
|
||
});
|
||
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);
|
||
}
|
||
await writeAudit({
|
||
action: "user.unban",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: id.data,
|
||
});
|
||
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);
|
||
}
|
||
const removedMemberships = await removeAllMembershipsOfUser(target.id);
|
||
await writeAudit({
|
||
action: "user.remove",
|
||
actorId: ctx.actor.id,
|
||
targetType: "user",
|
||
targetId: target.id,
|
||
diff: { name: target.name, removedMemberships },
|
||
});
|
||
revalidatePath(PATH);
|
||
return ok(undefined);
|
||
}
|