diff --git a/src/app/(app)/admin/kullanicilar/page.tsx b/src/app/(app)/admin/kullanicilar/page.tsx index 6875a05..87f8070 100644 --- a/src/app/(app)/admin/kullanicilar/page.tsx +++ b/src/app/(app)/admin/kullanicilar/page.tsx @@ -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 ( <> - + ); } diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx index 7cc8092..f9a61b8 100644 --- a/src/app/(app)/admin/layout.tsx +++ b/src/app/(app)/admin/layout.tsx @@ -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; } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 7d40c44..d3879db 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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 ( <> }> + {children} + + ); +} + +async function AuthenticatedShell({ children }: { children: React.ReactNode }) { + const user = await requireSession(); return ( + {/* FAZ 5: kullanıcının üye olduğu haneler */} {/* Mobilde alt menü yüksekliği kadar boşluk */}
{children}
diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index 3d0e25a..7763127 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -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; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1c00480..c6bca9f 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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 (
+ + + diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 5edfb33..47b26c1 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -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" } }, ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 0abc6c8..24c584c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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"); } diff --git a/src/components/layout/app-shell-fallback.tsx b/src/components/layout/app-shell-fallback.tsx new file mode 100644 index 0000000..3b22624 --- /dev/null +++ b/src/components/layout/app-shell-fallback.tsx @@ -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 ( +
+
+ + {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", -};