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:
@@ -1,18 +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 { listAdminUsers } from "@/features/admin-users/queries";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.adminUsers.title };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
// FAZ 3: const users = await listUsers() (Better Auth admin API)
|
||||
export default async function AdminUsersPage() {
|
||||
const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
|
||||
<UsersTable initialUsers={MOCK_ADMIN_USERS} currentUserId={MOCK_SESSION_USER.id} />
|
||||
<UsersTable users={users} currentUserId={admin.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
/** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireRole("admin");
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireSession } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
@@ -51,8 +51,8 @@ async function SummaryCards() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = MOCK_SESSION_USER;
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireSession();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { BottomNav } from "@/components/layout/bottom-nav";
|
||||
import { QuickAddProvider } from "@/components/layout/quick-add";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { MOCK_HOUSEHOLDS } from "@/features/households/mock";
|
||||
import { requireSession } from "@/lib/dal";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle.
|
||||
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login.
|
||||
* Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
|
||||
* kullanıcıya bağlı kabuk Suspense içinde render edilir. Her sayfa ayrıca kendi yetkisini
|
||||
* DAL üzerinden kontrol eder (layout istemci gezinmesinde yeniden çalışmaz).
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = MOCK_SESSION_USER;
|
||||
return (
|
||||
<Suspense fallback={<AppShellFallback />}>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireSession();
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<QuickAddProvider>
|
||||
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
|
||||
<SidebarInset>
|
||||
{/* FAZ 5: kullanıcının üye olduğu haneler */}
|
||||
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} />
|
||||
{/* Mobilde alt menü yüksekliği kadar boşluk */}
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { looksLikeEmail, normalizeUsername } from "@/shared/schemas/common";
|
||||
import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -20,11 +22,23 @@ export function LoginForm() {
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(_values: LoginInput) {
|
||||
async function onSubmit({ identifier, password }: LoginInput) {
|
||||
setFormError(null);
|
||||
// FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const value = identifier.trim();
|
||||
const { error } = looksLikeEmail(value)
|
||||
? await authClient.signIn.email({ email: value.toLowerCase(), password })
|
||||
: await authClient.signIn.username({ username: normalizeUsername(value), password });
|
||||
|
||||
if (error) {
|
||||
// Hangi bilginin yanlış olduğu söylenmez (hesap tahmini zorlaşır).
|
||||
if (error.status === 429) setFormError(tr.auth.tooManyAttempts);
|
||||
else if (error.code === "BANNED_USER") setFormError(tr.auth.banned);
|
||||
else setFormError(tr.auth.invalidCredentials);
|
||||
form.setValue("password", "");
|
||||
return;
|
||||
}
|
||||
router.replace("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const submitting = form.formState.isSubmitting;
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Logo } from "@/components/brand/logo";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getCurrentUser } from "@/lib/dal";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const metadata: Metadata = { title: tr.auth.loginTitle };
|
||||
|
||||
/** Oturum açıksa dashboard'a yönlendirir (istek anında, Suspense içinde). */
|
||||
async function RedirectIfSignedIn() {
|
||||
const user = await getCurrentUser();
|
||||
if (user) redirect("/dashboard");
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex w-full max-w-sm flex-col items-center gap-6">
|
||||
<Suspense fallback={null}>
|
||||
<RedirectIfSignedIn />
|
||||
</Suspense>
|
||||
<Logo priority className="scale-125" />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { connection } from "next/server";
|
||||
import { pingDb } from "@/lib/db/mongo";
|
||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK).
|
||||
* Faz 3'te veritabanı ping'i eklenecek; DB erişilemezse 503 dönecek.
|
||||
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). Veritabanına erişilemezse 503.
|
||||
* İç hata detayı döndürülmez.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||
await connection();
|
||||
const dbOk = await pingDb();
|
||||
return Response.json(
|
||||
{
|
||||
status: "ok",
|
||||
status: dbOk ? "ok" : "degraded",
|
||||
db: dbOk ? "ok" : "unreachable",
|
||||
version: APP_VERSION,
|
||||
commit: APP_COMMIT,
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
{ status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */
|
||||
/** Oturum yoksa proxy zaten /login'e yönlendirir; varsa dashboard. */
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Oturum okunurken gösterilen kabuk iskeleti (yan menü + üst bar + içerik). */
|
||||
export function AppShellFallback() {
|
||||
return (
|
||||
<div role="status" aria-label={tr.common.loading} className="flex min-h-dvh w-full">
|
||||
<div className="hidden w-64 shrink-0 flex-col gap-3 border-r bg-sidebar p-3 md:flex">
|
||||
<Skeleton className="h-8 w-28" />
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="ml-auto size-8 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4 md:p-6">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={logout}>
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
|
||||
<LogOut />
|
||||
{tr.userMenu.logout}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -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,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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user