From 3644e081b189d0256f18abebea7cee2f9dd91679 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(households):=20hane=20se=C3=A7ici,=20ayarl?= =?UTF-8?q?ar,=20admin=20hane=20y=C3=B6netimi=20ve=20hane-yok=20sayfas?= =?UTF-8?q?=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/admin/haneler/page.tsx | 18 ++ src/app/(app)/ayarlar/page.tsx | 50 +++ src/app/(app)/dashboard/page.tsx | 6 +- src/app/(app)/hane-yok/page.tsx | 39 +++ src/app/(app)/layout.tsx | 21 +- src/components/layout/app-header.tsx | 9 +- src/components/layout/bottom-nav.tsx | 6 +- src/components/layout/household-switcher.tsx | 36 ++- src/components/layout/nav-config.ts | 2 +- src/features/admin-households/actions.ts | 74 +++++ .../components/households-admin.tsx | 306 ++++++++++++++++++ .../components/user-select.tsx | 48 +++ src/features/admin-households/queries.ts | 10 + src/features/admin-users/actions.ts | 54 +++- src/features/households/actions.ts | 60 ++++ .../components/household-settings-form.tsx | 228 +++++++++++++ .../households/components/members-list.tsx | 169 ++++++++++ .../components/settings-members.tsx | 21 ++ src/features/households/mock.ts | 7 - src/features/households/service.int.test.ts | 207 ++++++++++++ src/i18n/tr.ts | 85 +++++ 21 files changed, 1427 insertions(+), 29 deletions(-) create mode 100644 src/app/(app)/admin/haneler/page.tsx create mode 100644 src/app/(app)/ayarlar/page.tsx create mode 100644 src/app/(app)/hane-yok/page.tsx create mode 100644 src/features/admin-households/actions.ts create mode 100644 src/features/admin-households/components/households-admin.tsx create mode 100644 src/features/admin-households/components/user-select.tsx create mode 100644 src/features/admin-households/queries.ts create mode 100644 src/features/households/actions.ts create mode 100644 src/features/households/components/household-settings-form.tsx create mode 100644 src/features/households/components/members-list.tsx create mode 100644 src/features/households/components/settings-members.tsx delete mode 100644 src/features/households/mock.ts create mode 100644 src/features/households/service.int.test.ts diff --git a/src/app/(app)/admin/haneler/page.tsx b/src/app/(app)/admin/haneler/page.tsx new file mode 100644 index 0000000..046001c --- /dev/null +++ b/src/app/(app)/admin/haneler/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/page-header"; +import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin"; +import { getAdminHouseholdsData } from "@/features/admin-households/queries"; +import { tr } from "@/i18n/tr"; +import { requireRole } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.adminHouseholds.title }; + +export default async function AdminHouseholdsPage() { + const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/ayarlar/page.tsx b/src/app/(app)/ayarlar/page.tsx new file mode 100644 index 0000000..47f0125 --- /dev/null +++ b/src/app/(app)/ayarlar/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form"; +import { SettingsMembers } from "@/features/households/components/settings-members"; +import { getHouseholdDetail } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.settings.title }; + +export default async function SettingsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const detail = await getHouseholdDetail(user.id, household.id); + if (!detail) notFound(); + const isOwner = household.role === "owner"; + const t = tr.settings; + + return ( + <> + +
+ + + {t.household} + {isOwner ? t.householdDescription : t.ownerOnly} + + + + + + + + {t.members} + {t.membersDescription} + + + + + +
+ + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index d3879db..c69c6a3 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,7 +4,7 @@ import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { tr } from "@/i18n/tr"; -import { requireSession } from "@/lib/dal"; +import { requireActiveHousehold } from "@/lib/dal"; import { cn } from "@/lib/utils"; import { formatMoney } from "@/shared/format"; @@ -52,12 +52,12 @@ async function SummaryCards() { } export default async function DashboardPage() { - const user = await requireSession(); + const { user, household } = await requireActiveHousehold("viewer"); return ( <> }> diff --git a/src/app/(app)/hane-yok/page.tsx b/src/app/(app)/hane-yok/page.tsx new file mode 100644 index 0000000..13570a3 --- /dev/null +++ b/src/app/(app)/hane-yok/page.tsx @@ -0,0 +1,39 @@ +import { House } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { tr } from "@/i18n/tr"; +import { getHouseholdContext } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.households.noHousehold.title }; + +/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */ +export default async function NoHouseholdPage() { + const ctx = await getHouseholdContext(); + if (!ctx) redirect("/login"); + if (ctx.household) redirect("/dashboard"); + const t = tr.households.noHousehold; + return ( +
+ + + + + + {t.title} + {t.description} + + {ctx.user.role === "admin" ? ( + +

{t.adminHint}

+ +
+ ) : null} +
+
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 439af9a..8d26e58 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { AppHeader } from "@/components/layout/app-header"; import { AppShellFallback } from "@/components/layout/app-shell-fallback"; @@ -5,8 +6,8 @@ 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_HOUSEHOLDS } from "@/features/households/mock"; -import { requireSession } from "@/lib/dal"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { getHouseholdContext } from "@/lib/dal"; import { APP_VERSION } from "@/lib/version"; /** @@ -23,18 +24,26 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { } async function AuthenticatedShell({ children }: { children: React.ReactNode }) { - const user = await requireSession(); + const ctx = await getHouseholdContext(); + if (!ctx) redirect("/login"); + const { user, household, memberships } = ctx; + const canWrite = household ? hasHouseholdRole(household.role, "member") : false; 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/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index d0e0d20..291acf7 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -11,18 +11,23 @@ import { UserMenu } from "./user-menu"; export function AppHeader({ user, households, + activeHouseholdId, + canWrite, version, }: { user: SessionUser; households: HouseholdSummary[]; + activeHouseholdId: string | undefined; + /** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */ + canWrite: boolean; version: string; }) { return (
- +
- + {canWrite ? : null}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index ce8bc15..812d6d2 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -40,7 +40,7 @@ function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) } /** Mobil alt gezinme çubuğu (md altı). */ -export function BottomNav() { +export function BottomNav({ canWrite }: { canWrite: boolean }) { const pathname = usePathname(); const { openQuickAdd } = useQuickAdd(); const { setOpenMobile } = useSidebar(); @@ -57,8 +57,10 @@ export function BottomNav() { diff --git a/src/components/layout/household-switcher.tsx b/src/components/layout/household-switcher.tsx index 733738f..73df1b0 100644 --- a/src/components/layout/household-switcher.tsx +++ b/src/components/layout/household-switcher.tsx @@ -1,7 +1,8 @@ "use client"; import { House } from "lucide-react"; -import { useState } from "react"; +import { useTransition } from "react"; +import { toast } from "sonner"; import { Select, SelectContent, @@ -9,17 +10,38 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { switchHouseholdAction } from "@/features/households/actions"; import type { HouseholdSummary } from "@/features/households/types"; import { tr } from "@/i18n/tr"; -/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ -export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { - const [value, setValue] = useState(households[0]?.id); - const current = households.find((h) => h.id === value); +/** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */ +export function HouseholdSwitcher({ + households, + activeId, +}: { + households: HouseholdSummary[]; + activeId: string | undefined; +}) { + const [pending, startTransition] = useTransition(); + const current = households.find((h) => h.id === activeId); if (households.length === 0) return null; + + function onChange(id: string) { + if (id === activeId) return; + startTransition(async () => { + const result = await switchHouseholdAction(id); + if (result.ok) toast.success(tr.household.switched(result.data.name)); + else toast.error(result.error); + }); + } + return ( - + {/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */} {current?.name} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 1ede442..62fbfd2 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -25,7 +25,7 @@ export type NavItem = { }; /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ -export const CURRENT_PHASE = 2; +export const CURRENT_PHASE = 5; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/admin-households/actions.ts b/src/features/admin-households/actions.ts new file mode 100644 index 0000000..972297f --- /dev/null +++ b/src/features/admin-households/actions.ts @@ -0,0 +1,74 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + adminAddMember, + adminCreateHousehold, + adminRemoveMember, + adminSetMemberRole, +} from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getActionUser } from "@/lib/dal"; +import { + addMemberSchema, + householdCreateSchema, + householdRoleSchema, +} from "@/shared/schemas/household"; + +/** Sistem admini: hane ve üyelik yönetimi. Hanenin finansal verisine dokunmaz. */ + +const PATH = "/admin/haneler"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +export async function createHouseholdAction(input: unknown): Promise> { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const parsed = householdCreateSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await adminCreateHousehold(admin.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function addMemberAction(householdId: unknown, input: unknown): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const parsed = addMemberSchema.safeParse(input); + if (!hid.success || !parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await adminAddMember(admin.id, hid.data, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function adminSetMemberRoleAction( + householdId: unknown, + userId: unknown, + role: unknown, +): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const uid = idSchema.safeParse(userId); + const r = householdRoleSchema.safeParse(role); + if (!hid.success || !uid.success || !r.success) return fail(tr.common.genericError); + const result = await adminSetMemberRole(admin.id, hid.data, uid.data, r.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function adminRemoveMemberAction( + householdId: unknown, + userId: unknown, +): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const uid = idSchema.safeParse(userId); + if (!hid.success || !uid.success) return fail(tr.common.genericError); + const result = await adminRemoveMember(admin.id, hid.data, uid.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} diff --git a/src/features/admin-households/components/households-admin.tsx b/src/features/admin-households/components/households-admin.tsx new file mode 100644 index 0000000..9c18aaf --- /dev/null +++ b/src/features/admin-households/components/households-admin.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Plus, UserPlus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { MembersList } from "@/features/households/components/members-list"; +import type { AdminHouseholdRow, UserOption } from "@/features/households/service"; +import { HOUSEHOLD_ROLES } from "@/features/households/types"; +import { tr } from "@/i18n/tr"; +import { + addMemberSchema, + householdCreateSchema, + type AddMemberInput, + type HouseholdCreateInput, +} from "@/shared/schemas/household"; +import { + addMemberAction, + adminRemoveMemberAction, + adminSetMemberRoleAction, + createHouseholdAction, +} from "../actions"; +import { UserSelect } from "./user-select"; + +const t = tr.adminHouseholds; + +function CreateHouseholdDialog({ + open, + onOpenChange, + users, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + users: UserOption[]; +}) { + const form = useForm({ + resolver: zodResolver(householdCreateSchema), + defaultValues: { name: "", ownerUserId: "" }, + }); + useEffect(() => { + if (open) form.reset({ name: "", ownerUserId: "" }); + }, [open, form]); + + async function onSubmit(values: HouseholdCreateInput) { + const result = await createHouseholdAction(values); + if (result.ok) { + toast.success(t.created(values.name)); + onOpenChange(false); + } else { + form.setError("root", { message: result.error }); + } + } + + return ( + + + + + } + > +
+ + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.owner} + + {fieldState.invalid ? : null} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
+
+ ); +} + +function AddMemberDialog({ + household, + users, + onOpenChange, +}: { + household: AdminHouseholdRow | null; + users: UserOption[]; + onOpenChange: (o: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(addMemberSchema), + defaultValues: { userId: "", role: "member" }, + }); + useEffect(() => { + if (household) form.reset({ userId: "", role: "member" }); + }, [household, form]); + + async function onSubmit(values: AddMemberInput) { + if (!household) return; + const result = await addMemberAction(household.id, values); + if (result.ok) { + toast.success(t.memberAdded(users.find((u) => u.id === values.userId)?.name ?? "")); + onOpenChange(false); + } else { + form.setError("root", { message: result.error }); + } + } + + return ( + + + + + } + > +
+ + ( + + {t.user} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.role} + + + {tr.household.roleHints[field.value]} + + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
+
+ ); +} + +export function HouseholdsAdmin({ + households, + users, + currentUserId, +}: { + households: AdminHouseholdRow[]; + users: UserOption[]; + currentUserId: string; +}) { + const [creating, setCreating] = useState(false); + const [addingTo, setAddingTo] = useState(null); + + return ( +
+
+

{t.privacyNote}

+ +
+ + {households.length === 0 ? ( +

+ {t.empty} +

+ ) : ( +
+ {households.map((h) => ( + + + {h.name} + + {t.members(h.memberCount)} + {h.owners.length ? ( + + · {t.owners}: {h.owners.join(", ")} + + ) : ( + {t.noOwner} + )} + + + + + + + adminSetMemberRoleAction(h.id, userId, role)} + onRemove={(userId) => adminRemoveMemberAction(h.id, userId)} + /> + + + ))} +
+ )} + + + !o && setAddingTo(null)} + /> +
+ ); +} diff --git a/src/features/admin-households/components/user-select.tsx b/src/features/admin-households/components/user-select.tsx new file mode 100644 index 0000000..894b16f --- /dev/null +++ b/src/features/admin-households/components/user-select.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { UserOption } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; + +/** Kullanıcı seçici (askıdakiler işaretli). */ +export function UserSelect({ + id, + users, + value, + onChange, + invalid, +}: { + id: string; + users: UserOption[]; + value: string; + onChange: (id: string) => void; + invalid?: boolean; +}) { + const selected = users.find((u) => u.id === value); + return ( + + ); +} diff --git a/src/features/admin-households/queries.ts b/src/features/admin-households/queries.ts new file mode 100644 index 0000000..4db9444 --- /dev/null +++ b/src/features/admin-households/queries.ts @@ -0,0 +1,10 @@ +import "server-only"; +import { adminListHouseholds, adminListUserOptions } from "@/features/households/service"; +import { requireRole } from "@/lib/dal"; + +/** Admin: haneler (üyeleriyle) ve hane atamaları için kullanıcı listesi. */ +export async function getAdminHouseholdsData() { + await requireRole("admin"); + const [households, users] = await Promise.all([adminListHouseholds(), adminListUserOptions()]); + return { households, users }; +} diff --git a/src/features/admin-users/actions.ts b/src/features/admin-users/actions.ts index 38e62df..fac87e0 100644 --- a/src/features/admin-users/actions.ts +++ b/src/features/admin-users/actions.ts @@ -7,7 +7,9 @@ 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 { @@ -102,8 +104,9 @@ export async function createUserAction(input: unknown): Promise { const parsed = userCreateSchema.safeParse(input); if (!parsed.success) return fail(t.errors.checkFields); const v = parsed.data; + let createdId = ""; try { - await ctx.api.createUser({ + const created = await ctx.api.createUser({ headers: ctx.headers, body: { name: v.name, @@ -113,9 +116,17 @@ export async function createUserAction(input: unknown): Promise { 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); } @@ -148,6 +159,13 @@ export async function updateUserAction(userId: unknown, input: unknown): Promise } 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); } @@ -167,6 +185,13 @@ export async function setRoleAction(userId: unknown, role: unknown): Promise { } catch (error) { return mapAuthError(error); } + await writeAudit({ + action: "user.unban", + actorId: ctx.actor.id, + targetType: "user", + targetId: id.data, + }); revalidatePath(PATH); return ok(undefined); } @@ -245,6 +289,14 @@ export async function removeUserAction(userId: unknown): Promise { } 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); } diff --git a/src/features/households/actions.ts b/src/features/households/actions.ts new file mode 100644 index 0000000..6d38f3d --- /dev/null +++ b/src/features/households/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeActiveHouseholdId } from "@/lib/active-household"; +import { getCurrentUser, getHouseholdActionContext } from "@/lib/dal"; +import { householdRoleSchema, householdSettingsSchema } from "@/shared/schemas/household"; +import { removeMember, resolveMembership, setMemberRole, updateHouseholdSettings } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +/** Aktif haneyi değiştirir: önce üyelik doğrulanır, sonra cookie yazılır. */ +export async function switchHouseholdAction( + householdId: unknown, +): Promise> { + const user = await getCurrentUser(); + const id = idSchema.safeParse(householdId); + if (!user || !id.success) return fail(tr.common.unauthorized); + const membership = await resolveMembership(user.id, id.data, "viewer"); + if (!membership) return fail(tr.common.unauthorized); + await writeActiveHouseholdId(id.data); + revalidatePath("/", "layout"); + return ok({ name: membership.name }); +} + +/** Owner: hane adı ve ayarları (aktif hane). */ +export async function updateHouseholdSettingsAction(input: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = householdSettingsSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await updateHouseholdSettings(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +/** Owner: aktif hanedeki üyenin rolü. */ +export async function setMemberRoleAction(userId: unknown, role: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const uid = idSchema.safeParse(userId); + const r = householdRoleSchema.safeParse(role); + if (!uid.success || !r.success) return fail(tr.common.genericError); + const result = await setMemberRole(ctx.user.id, ctx.household.id, uid.data, r.data); + if (result.ok) revalidatePath("/ayarlar"); + return result; +} + +/** Owner: üyeyi aktif haneden çıkarır. */ +export async function removeMemberAction(userId: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const uid = idSchema.safeParse(userId); + if (!uid.success) return fail(tr.common.genericError); + const result = await removeMember(ctx.user.id, ctx.household.id, uid.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} diff --git a/src/features/households/components/household-settings-form.tsx b/src/features/households/components/household-settings-form.tsx new file mode 100644 index 0000000..e5e6299 --- /dev/null +++ b/src/features/households/components/household-settings-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { householdSettingsSchema, type HouseholdSettings } from "@/shared/schemas/household"; +import { updateHouseholdSettingsAction } from "../actions"; + +const t = tr.settings.fields; + +/** Form şeması: eşik TL (tam sayı) olarak girilir, kuruşa sunucuya giderken çevrilir. */ +const formSchema = householdSettingsSchema + .omit({ extraStoreSavingThresholdMinor: true, budgetMonthStartDay: true }) + .extend({ + budgetMonthStartDay: z.number().int().min(1).max(28), + extraStoreSavingThresholdTl: z + .number({ error: tr.validation.wholeNumber }) + .int(tr.validation.wholeNumber) + .min(0) + .max(100_000), + }); +type FormValues = z.infer; + +const DAYS = Array.from({ length: 28 }, (_, i) => i + 1); + +export function HouseholdSettingsForm({ + settings, + canEdit, +}: { + settings: HouseholdSettings; + canEdit: boolean; +}) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: settings.name, + budgetMonthStartDay: settings.budgetMonthStartDay, + installmentBudgetMode: settings.installmentBudgetMode, + dailyPlanMode: settings.dailyPlanMode, + includePrivateAccountsInTotals: settings.includePrivateAccountsInTotals, + extraStoreSavingThresholdTl: Math.round(settings.extraStoreSavingThresholdMinor / 100), + }, + }); + + async function onSubmit({ extraStoreSavingThresholdTl, ...rest }: FormValues) { + const result = await updateHouseholdSettingsAction({ + ...rest, + extraStoreSavingThresholdMinor: extraStoreSavingThresholdTl * 100, + }); + if (result.ok) { + toast.success(tr.settings.saved); + form.reset({ ...rest, extraStoreSavingThresholdTl }); + } else { + toast.error(result.error); + } + } + + const submitting = form.formState.isSubmitting; + + return ( +
+ {/* Sahip olmayanlar için tüm alanlar salt okunur. */} +
+ + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.budgetMonthStartDay} + + + {t.budgetMonthStartDayHint} + + + )} + /> + ( + + {t.installmentBudgetMode} + + + )} + /> + ( + + {t.dailyPlanMode} + + + )} + /> + ( + + {t.extraStoreSavingThreshold} + field.onChange(e.target.valueAsNumber)} + aria-invalid={fieldState.invalid} + aria-describedby="hh-threshold-hint" + /> + + {t.extraStoreSavingThresholdHint} + + {fieldState.invalid ? : null} + + )} + /> + ( + + field.onChange(v === true)} + disabled={!canEdit} + /> + + {t.includePrivateAccountsInTotals} + + + )} + /> + {canEdit ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/features/households/components/members-list.tsx b/src/features/households/components/members-list.tsx new file mode 100644 index 0000000..230dcbc --- /dev/null +++ b/src/features/households/components/members-list.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { UserMinus } from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { initials } from "@/components/layout/user-menu"; +import { tr } from "@/i18n/tr"; +import type { ActionResult } from "@/lib/action-result"; +import { HOUSEHOLD_ROLES, type HouseholdRole } from "../types"; + +export type MemberRow = { + userId: string; + name: string; + username: string; + role: HouseholdRole; + banned: boolean; +}; + +type Props = { + members: MemberRow[]; + householdName: string; + currentUserId: string; + canManage: boolean; + onSetRole: (userId: string, role: HouseholdRole) => Promise; + onRemove: (userId: string) => Promise; +}; + +/** Hane üyeleri: owner (veya admin) rol değiştirir / haneden çıkarır. Kurallar sunucuda. */ +export function MembersList({ + members, + householdName, + currentUserId, + canManage, + onSetRole, + onRemove, +}: Props) { + const [pending, startTransition] = useTransition(); + const [removing, setRemoving] = useState(null); + const t = tr.settings; + + function changeRole(member: MemberRow, role: HouseholdRole) { + startTransition(async () => { + const result = await onSetRole(member.userId, role); + if (result.ok) toast.success(t.roleChanged(member.name)); + else toast.error(result.error); + }); + } + + function confirmRemove() { + const member = removing; + if (!member) return; + startTransition(async () => { + const result = await onRemove(member.userId); + if (result.ok) { + toast.success(t.memberRemoved(member.name)); + setRemoving(null); + } else { + toast.error(result.error); + } + }); + } + + return ( + <> +
    + {members.map((m) => ( +
  • + + + {initials(m.name)} + + +
    +

    + {m.name} + {m.userId === currentUserId ? ( + + ({tr.adminUsers.you}) + + ) : null} +

    +

    @{m.username}

    +
    + {m.banned ? {t.banned} : null} + {canManage ? ( +
    + + +
    + ) : ( + + {tr.household.roles[m.role]} + + )} +
  • + ))} +
+ + !open && setRemoving(null)}> + + + {t.removeTitle} + + {removing ? t.removeDescription(removing.name, householdName) : null} + + + + {tr.common.cancel} + + + + + + ); +} diff --git a/src/features/households/components/settings-members.tsx b/src/features/households/components/settings-members.tsx new file mode 100644 index 0000000..c8632f9 --- /dev/null +++ b/src/features/households/components/settings-members.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { removeMemberAction, setMemberRoleAction } from "../actions"; +import type { HouseholdRole } from "../types"; +import { MembersList, type MemberRow } from "./members-list"; + +/** /ayarlar: aktif hanenin üyeleri (owner action'larına bağlı). */ +export function SettingsMembers(props: { + members: MemberRow[]; + householdName: string; + currentUserId: string; + canManage: boolean; +}) { + return ( + setMemberRoleAction(userId, role)} + onRemove={(userId) => removeMemberAction(userId)} + /> + ); +} diff --git a/src/features/households/mock.ts b/src/features/households/mock.ts deleted file mode 100644 index 04e0c4b..0000000 --- a/src/features/households/mock.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { HouseholdSummary } from "./types"; - -/** FAZ 2 GEÇİCİ: Faz 5'te kullanıcının üye olduğu hanelerle değiştirilecek. */ -export const MOCK_HOUSEHOLDS: HouseholdSummary[] = [ - { id: "h_cakir", name: "Çakır Ailesi", role: "owner" }, - { id: "h_anne", name: "Annemler", role: "viewer" }, -]; diff --git a/src/features/households/service.int.test.ts b/src/features/households/service.int.test.ts new file mode 100644 index 0000000..cf265b4 --- /dev/null +++ b/src/features/households/service.int.test.ts @@ -0,0 +1,207 @@ +import mongoose, { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { AuditLogModel } from "@/models/audit-log"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { runSeed } from "../../../scripts/seed/run"; +import { + adminCreateHousehold, + adminSetMemberRole, + getHouseholdDetail, + listMemberships, + removeAllMembershipsOfUser, + removeMember, + resolveMembership, + setMemberRole, + updateHouseholdSettings, +} from "./service"; + +/** + * VERİ İZOLASYONU (Bölüm 5): başka hanenin verisine erişim imkânsız olmalı. + * Seed verisi: Çakır Ailesi (admin+ayse owner, emre member, zeynep viewer, ozan member), + * Annemler (fatma owner, mehmet member, ayse viewer), burak = hanesi olmayan admin. + */ + +const ids = new Map(); +let cakir = ""; +let annemler = ""; + +const settings = { + name: "Çakır Ailesi", + budgetMonthStartDay: 15, + installmentBudgetMode: "purchase_month" as const, + dailyPlanMode: "even" as const, + includePrivateAccountsInTotals: false, + extraStoreSavingThresholdMinor: 25_000, +}; + +beforeAll(async () => { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); + await runSeed({ log: () => {} }); + for (const u of await getDb().collection("user").find().toArray()) + ids.set(String(u.username), String(u._id)); + cakir = String((await HouseholdModel.findOne({ name: "Çakır Ailesi" }))!._id); + annemler = String((await HouseholdModel.findOne({ name: "Annemler" }))!._id); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await mongoose.disconnect(); + await getMongoClient().close(); +}); + +const id = (username: string) => ids.get(username)!; + +describe("üyelik çözümleme (izolasyon çekirdeği)", () => { + it("üye olmayan kullanıcı başka hanenin üyeliğini çözemez", async () => { + expect(await resolveMembership(id("fatma"), cakir)).toBeNull(); + expect(await resolveMembership(id("emre"), annemler)).toBeNull(); + }); + + it("rol yetmezse null döner", async () => { + expect((await resolveMembership(id("ayse"), annemler, "viewer"))?.role).toBe("viewer"); + expect(await resolveMembership(id("ayse"), annemler, "member")).toBeNull(); + expect(await resolveMembership(id("zeynep"), cakir, "member")).toBeNull(); + expect((await resolveMembership(id("emre"), cakir, "member"))?.role).toBe("member"); + }); + + it("hanesi olmayan admin hiçbir haneyi göremez (admin ayrıcalığı yok)", async () => { + expect(await listMemberships(id("burak"))).toEqual([]); + expect(await resolveMembership(id("burak"), cakir)).toBeNull(); + expect(await getHouseholdDetail(id("burak"), cakir)).toBeNull(); + }); + + it("geçersiz ve uydurma kimlikler reddedilir", async () => { + const forged = new Types.ObjectId().toHexString(); + expect(await resolveMembership(id("ayse"), forged)).toBeNull(); + expect(await resolveMembership(id("ayse"), "not-an-id")).toBeNull(); + expect(await resolveMembership(id("ayse"), { $ne: null } as unknown as string)).toBeNull(); + expect(await resolveMembership("", cakir)).toBeNull(); + }); + + it("kullanıcı yalnızca kendi hanelerini listeler", async () => { + expect((await listMemberships(id("ayse"))).map((m) => [m.name, m.role])).toEqual([ + ["Annemler", "viewer"], + ["Çakır Ailesi", "owner"], + ]); + expect((await listMemberships(id("fatma"))).map((m) => m.name)).toEqual(["Annemler"]); + }); +}); + +describe("hane ayrıntısı ve ayarlar", () => { + it("başka hanenin ayrıntısı okunamaz", async () => { + expect(await getHouseholdDetail(id("fatma"), cakir)).toBeNull(); + const own = await getHouseholdDetail(id("emre"), cakir); + expect(own?.members.map((m) => m.username).sort()).toEqual([ + "admin", + "ayse", + "emre", + "ozan", + "zeynep", + ]); + }); + + it("başka hanenin sahibi, üye ve izleyici ayarları değiştiremez", async () => { + for (const u of ["fatma", "emre", "zeynep", "burak"]) { + const res = await updateHouseholdSettings(id(u), cakir, settings); + expect(res.ok, u).toBe(false); + } + const h = await HouseholdModel.findById(cakir).lean(); + expect(h?.settings?.budgetMonthStartDay).toBe(1); + }); + + it("sahip ayarları değiştirir ve denetim kaydı yazılır", async () => { + const res = await updateHouseholdSettings(id("ayse"), cakir, settings); + expect(res.ok).toBe(true); + const h = await HouseholdModel.findById(cakir).lean(); + expect(h?.settings?.budgetMonthStartDay).toBe(15); + const audit = await AuditLogModel.findOne({ + action: "household.settings.update", + householdId: cakir, + }).lean(); + expect(audit?.actorId.toString()).toBe(id("ayse")); + expect((audit?.diff as Record).budgetMonthStartDay).toEqual({ + from: 1, + to: 15, + }); + }); +}); + +describe("üyelik değişiklikleri", () => { + it("üye ve izleyici rol değiştiremez, üye çıkaramaz", async () => { + expect((await setMemberRole(id("emre"), cakir, id("zeynep"), "owner")).ok).toBe(false); + expect((await removeMember(id("zeynep"), cakir, id("emre"))).ok).toBe(false); + }); + + it("bir hanenin sahibi başka hanedeki üyeliği etkileyemez", async () => { + // ayse Çakır'ın sahibi; fatma Çakır üyesi değil → işlem bulunamaz, Annemler'deki rolü değişmez. + expect((await setMemberRole(id("ayse"), cakir, id("fatma"), "viewer")).ok).toBe(false); + expect((await removeMember(id("ayse"), cakir, id("mehmet"))).ok).toBe(false); + // fatma Annemler'in sahibi; Çakır üzerinde yetkisi yok. + expect((await setMemberRole(id("fatma"), cakir, id("emre"), "viewer")).ok).toBe(false); + const fatma = await HouseholdMemberModel.findOne({ + householdId: annemler, + userId: id("fatma"), + }).lean(); + const mehmet = await HouseholdMemberModel.findOne({ + householdId: annemler, + userId: id("mehmet"), + }).lean(); + const emre = await HouseholdMemberModel.findOne({ + householdId: cakir, + userId: id("emre"), + }).lean(); + expect([fatma?.role, mehmet?.role, emre?.role]).toEqual(["owner", "member", "member"]); + }); + + it("son sahip düşürülemez; birden çok sahip varken düşürülebilir", async () => { + expect((await adminSetMemberRole(id("admin"), annemler, id("fatma"), "member")).ok).toBe(false); + expect((await setMemberRole(id("fatma"), annemler, id("fatma"), "member")).ok).toBe(false); + // Çakır'da iki sahip var (admin + ayse). + expect((await setMemberRole(id("ayse"), cakir, id("admin"), "member")).ok).toBe(true); + // Artık ayse son sahip. + expect((await setMemberRole(id("ayse"), cakir, id("ayse"), "member")).ok).toBe(false); + expect((await setMemberRole(id("ayse"), cakir, id("admin"), "owner")).ok).toBe(true); + expect( + await AuditLogModel.countDocuments({ action: "household.member.role", householdId: cakir }), + ).toBe(2); + }); + + it("sahip üyeyi haneden çıkarır; kullanıcı artık haneyi göremez", async () => { + expect((await removeMember(id("ayse"), cakir, id("ozan"))).ok).toBe(true); + expect(await resolveMembership(id("ozan"), cakir)).toBeNull(); + expect(await listMemberships(id("ozan"))).toEqual([]); + }); +}); + +describe("admin işlemleri", () => { + it("hane + ilk sahip + denetim kaydı tek transaction'da oluşur", async () => { + const res = await adminCreateHousehold(id("admin"), { + name: "Yazlık", + ownerUserId: id("burak"), + }); + expect(res.ok).toBe(true); + const hid = res.ok ? res.data.id : ""; + expect((await resolveMembership(id("burak"), hid, "owner"))?.name).toBe("Yazlık"); + expect( + await AuditLogModel.countDocuments({ action: "household.create", householdId: hid }), + ).toBe(1); + }); + + it("var olmayan kullanıcıyla hane oluşturulmaz", async () => { + const before = await HouseholdModel.countDocuments(); + const res = await adminCreateHousehold(id("admin"), { + name: "Hayalet", + ownerUserId: new Types.ObjectId().toHexString(), + }); + expect(res.ok).toBe(false); + expect(await HouseholdModel.countDocuments()).toBe(before); + }); + + it("kullanıcı silinince tüm üyelikleri temizlenir", async () => { + expect(await removeAllMembershipsOfUser(id("mehmet"))).toBe(1); + expect(await listMemberships(id("mehmet"))).toEqual([]); + }); +}); diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index e76d8ee..c40a814 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -63,6 +63,87 @@ export const tr = { label: "Aktif hane", select: "Hane seçin", roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" }, + roleHints: { + owner: "Hane ayarları, üyeler ve hesaplar", + member: "İşlem girer, liste ve stok günceller", + viewer: "Yalnızca görüntüler", + }, + switched: (name: string) => `Aktif hane: ${name}`, + }, + households: { + lastOwner: "Hanenin son sahibi düşürülemez veya çıkarılamaz.", + noHousehold: { + title: "Henüz bir haneye üye değilsiniz", + description: + "Bütçe ve diğer verileri görmek için bir haneye üye olmanız gerekir. Yöneticiden sizi bir haneye eklemesini isteyin.", + adminHint: + "Yönetici olarak Yönetim › Haneler sayfasından hane oluşturabilir ve üye ekleyebilirsiniz.", + adminLink: "Hanelere git", + }, + viewerReadOnly: "İzleyici rolündesiniz; bu hanede değişiklik yapamazsınız.", + }, + settings: { + title: "Ayarlar", + description: "Aktif hanenin ayarları ve üyeleri.", + household: "Hane", + householdDescription: "Bütçe ve planlama tercihleri tüm hane için geçerlidir.", + ownerOnly: "Bu ayarları yalnızca hane sahipleri değiştirebilir.", + fields: { + name: "Hane adı", + budgetMonthStartDay: "Bütçe ayı başlangıç günü", + budgetMonthStartDayHint: + "1 = takvim ayı. Örneğin 15 seçilirse bütçe ayı her ayın 15. günü başlar.", + installmentBudgetMode: "Taksitlerin bütçeye etkisi", + installmentModes: { + per_installment: "Her taksit kendi ayına", + purchase_month: "Tamamı harcama ayına", + }, + dailyPlanMode: "Günlük plan dağıtımı", + dailyPlanModes: { + even: "Değişken bütçe günlere eşit", + previous_month_pattern: "Önceki ayın harcama örüntüsüne göre", + }, + includePrivateAccountsInTotals: "“Yalnızca ben” hesapları hane toplamlarına dahil edilsin", + extraStoreSavingThreshold: "Ek mağaza tasarruf eşiği (TL)", + extraStoreSavingThresholdHint: + "Alışveriş planlayıcı, ek bir mağazayı yalnızca tasarruf bu tutarı aşarsa önerir.", + }, + saved: "Hane ayarları kaydedildi.", + members: "Üyeler", + membersDescription: "Hane içi roller. Yeni üyeyi yönetici ekler.", + memberActions: (name: string) => `${name} — işlemler`, + changeRole: "Rolü değiştir", + removeMember: "Haneden çıkar", + removeTitle: "Üye haneden çıkarılsın mı?", + removeDescription: (name: string, household: string) => + `${name}, “${household}” hanesine artık erişemeyecek. Kullanıcı hesabı silinmez.`, + removeConfirm: "Haneden çıkar", + roleChanged: (name: string) => `${name} için rol güncellendi.`, + memberRemoved: (name: string) => `${name} haneden çıkarıldı.`, + banned: "Askıda", + }, + adminHouseholds: { + title: "Haneler", + description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.", + newHousehold: "Yeni hane", + createTitle: "Yeni hane", + createDescription: "Hane oluşturulur ve seçilen kullanıcı ilk sahibi olur.", + name: "Hane adı", + owner: "İlk sahip", + selectUser: "Kullanıcı seçin", + created: (name: string) => `${name} hanesi oluşturuldu.`, + members: (n: number) => `${n} üye`, + owners: "Sahipler", + noOwner: "Sahibi yok", + addMember: "Üye ekle", + addMemberTitle: (household: string) => `${household} — üye ekle`, + addMemberDescription: "Kullanıcı zaten üyeyse rolü güncellenir.", + user: "Kullanıcı", + role: "Hane içi rol", + memberAdded: (name: string) => `${name} haneye eklendi.`, + empty: "Henüz hane yok.", + privacyNote: + "Yöneticiler haneleri ve üyelikleri yönetir; üyesi olmadıkları hanenin finansal verisini göremez.", }, userMenu: { open: "Kullanıcı menüsü", @@ -196,6 +277,10 @@ export const tr = { passwordMin: "Şifre en az 8 karakter olmalı", passwordMax: "Şifre en fazla 128 karakter olabilir", banReasonMax: "Neden en fazla 200 karakter olabilir", + householdNameMin: "Hane adı en az 2 karakter olmalı", + householdNameMax: "Hane adı en fazla 80 karakter olabilir", + selectUser: "Bir kullanıcı seçin", + wholeNumber: "Tam sayı girin", }, errors: { notFoundTitle: "Sayfa bulunamadı",