From 643f2de29b83efa5d3b4cc428d37e2ea6c83ec19 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 05:34:47 +0300 Subject: [PATCH] feat(team): manual-code invite flow + member management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant invite system without SMTP dependency. Designed for dev/early stage; promotes to email-driven later by adding SMTP to Appwrite. New schema: - invite_links table (code, email, role, status, expiresAt, invitedBy) with unique index on code, indexes on (tenantId,status) and (tenantId,email) New code: - lib/appwrite/audit.ts: logAudit() helper writes to audit_logs with X-Forwarded-For/User-Agent capture; never throws. - lib/appwrite/tenant-guard.ts: requireTenant() returns { user, tenantId, role, settings }; pulls highest role from team memberships. requireRole() guard. - lib/appwrite/team-actions.ts: * inviteMemberAction — creates short code (8 char nanoid-style), inserts invite_links row with team-scoped perms, returns shortUrl. Reuses existing pending invite for same email instead of duplicating. Blocks self-invite, blocks invite of existing members. * cancelInviteAction — owner/admin only, marks status=cancelled. * removeMemberAction — owner/admin only; protects self-removal and requires owner-on-owner. * updateMemberRoleAction — owner only. * resolveInviteCode — public-ish lookup by code (admin SDK). * acceptInviteAction — verifies session.email matches invite.email, creates membership via admin SDK, marks invite accepted. All mutations write to audit_logs. UI: - /d/[code] short-URL accept page (server). Logged-in matching user sees 'Daveti kabul et' button; non-matching user sees error; logged-out user gets sign-up / sign-in CTAs that preserve the code. - /settings/members page (server): InviteForm, PendingInvitesTable, MembersTable. Owner/admin gates respected; only owner can change roles. - Sign-up and sign-in forms accept ?invite=CODE (and ?email= for sign-up): hidden input -> server action redirects to /d/CODE on success. Other: - next.config.ts: removed eslint config block (deprecated in Next 16); kept typescript.ignoreBuildErrors for template legacy. --- next.config.ts | 1 - .../sign-in/components/login-form-1.tsx | 13 +- src/app/(auth)/sign-in/page.tsx | 11 +- .../sign-up/components/signup-form-1.tsx | 18 +- src/app/(auth)/sign-up/page.tsx | 11 +- .../members/components/invite-form.tsx | 114 ++++++ .../members/components/members-table.tsx | 158 ++++++++ .../components/pending-invites-table.tsx | 138 +++++++ src/app/(dashboard)/settings/members/page.tsx | 95 +++++ src/app/d/[code]/accept-invite-button.tsx | 50 +++ src/app/d/[code]/page.tsx | 141 +++++++ src/lib/appwrite/audit.ts | 43 ++ src/lib/appwrite/auth-actions.ts | 6 +- src/lib/appwrite/schema.ts | 16 + src/lib/appwrite/team-actions.ts | 377 ++++++++++++++++++ src/lib/appwrite/team-types.ts | 15 + src/lib/appwrite/tenant-guard.ts | 64 +++ 17 files changed, 1258 insertions(+), 13 deletions(-) create mode 100644 src/app/(dashboard)/settings/members/components/invite-form.tsx create mode 100644 src/app/(dashboard)/settings/members/components/members-table.tsx create mode 100644 src/app/(dashboard)/settings/members/components/pending-invites-table.tsx create mode 100644 src/app/(dashboard)/settings/members/page.tsx create mode 100644 src/app/d/[code]/accept-invite-button.tsx create mode 100644 src/app/d/[code]/page.tsx create mode 100644 src/lib/appwrite/audit.ts create mode 100644 src/lib/appwrite/team-actions.ts create mode 100644 src/lib/appwrite/team-types.ts create mode 100644 src/lib/appwrite/tenant-guard.ts diff --git a/next.config.ts b/next.config.ts index e4572f0..2e81ecf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,7 +8,6 @@ const nextConfig: NextConfig = { // TODO: re-enable once template files (chart.tsx, data-table-toolbar.tsx) are cleaned up. typescript: { ignoreBuildErrors: true }, - eslint: { ignoreDuringBuilds: true }, // Image optimization images: { diff --git a/src/app/(auth)/sign-in/components/login-form-1.tsx b/src/app/(auth)/sign-in/components/login-form-1.tsx index 7782f29..43634ae 100644 --- a/src/app/(auth)/sign-in/components/login-form-1.tsx +++ b/src/app/(auth)/sign-in/components/login-form-1.tsx @@ -13,7 +13,11 @@ import { cn } from "@/lib/utils"; import { signInAction } from "@/lib/appwrite/auth-actions"; import { initialAuthState } from "@/lib/appwrite/auth-types"; -export function LoginForm1({ className, ...props }: React.ComponentProps<"div">) { +export function LoginForm1({ + className, + inviteCode, + ...props +}: React.ComponentProps<"div"> & { inviteCode?: string }) { const [state, formAction, isPending] = useActionState(signInAction, initialAuthState); return ( @@ -21,6 +25,7 @@ export function LoginForm1({ className, ...props }: React.ComponentProps<"div">)
+ {inviteCode && }
@@ -31,6 +36,12 @@ export function LoginForm1({ className, ...props }: React.ComponentProps<"div">)
+ {inviteCode && ( +

+ Davete katılmak için giriş yapın. +

+ )} +

Tekrar hoş geldiniz

diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 6e7f735..526efbb 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -3,14 +3,19 @@ import { redirect } from "next/navigation"; import { LoginForm1 } from "./components/login-form-1"; import { getCurrentUser } from "@/lib/appwrite/server"; -export default async function Page() { +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ invite?: string }>; +}) { + const { invite } = await searchParams; const user = await getCurrentUser(); - if (user) redirect("/dashboard"); + if (user) redirect(invite ? `/d/${invite}` : "/dashboard"); return (

- +
); diff --git a/src/app/(auth)/sign-up/components/signup-form-1.tsx b/src/app/(auth)/sign-up/components/signup-form-1.tsx index 6162c06..2e959d6 100644 --- a/src/app/(auth)/sign-up/components/signup-form-1.tsx +++ b/src/app/(auth)/sign-up/components/signup-form-1.tsx @@ -13,7 +13,12 @@ import { cn } from "@/lib/utils"; import { signUpAction } from "@/lib/appwrite/auth-actions"; import { initialAuthState } from "@/lib/appwrite/auth-types"; -export function SignupForm1({ className, ...props }: React.ComponentProps<"div">) { +export function SignupForm1({ + className, + inviteCode, + prefilledEmail, + ...props +}: React.ComponentProps<"div"> & { inviteCode?: string; prefilledEmail?: string }) { const [state, formAction, isPending] = useActionState(signUpAction, initialAuthState); return ( @@ -23,6 +28,7 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div"> + {inviteCode && }
@@ -34,9 +40,13 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div">
-

Hesap oluşturun

+

+ {inviteCode ? "Davete katıl" : "Hesap oluşturun"} +

- Birkaç saniye içinde hesabınız hazır + {inviteCode + ? "Hesap oluşturduktan sonra çalışma alanına otomatik katılacaksınız" + : "Birkaç saniye içinde hesabınız hazır"}

@@ -60,6 +70,8 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div"> type="email" placeholder="ornek@firma.com" autoComplete="email" + defaultValue={prefilledEmail} + readOnly={Boolean(prefilledEmail)} required />
diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index a3f16fe..41aae89 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -3,14 +3,19 @@ import { redirect } from "next/navigation"; import { SignupForm1 } from "./components/signup-form-1"; import { getCurrentUser } from "@/lib/appwrite/server"; -export default async function SignUpPage() { +export default async function SignUpPage({ + searchParams, +}: { + searchParams: Promise<{ invite?: string; email?: string }>; +}) { + const { invite, email } = await searchParams; const user = await getCurrentUser(); - if (user) redirect("/dashboard"); + if (user) redirect(invite ? `/d/${invite}` : "/dashboard"); return (
- +
); diff --git a/src/app/(dashboard)/settings/members/components/invite-form.tsx b/src/app/(dashboard)/settings/members/components/invite-form.tsx new file mode 100644 index 0000000..7892cd3 --- /dev/null +++ b/src/app/(dashboard)/settings/members/components/invite-form.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useActionState, useEffect, useRef, useState } from "react"; +import { Check, Copy, Loader2, UserPlus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { inviteMemberAction } from "@/lib/appwrite/team-actions"; +import { initialInviteState } from "@/lib/appwrite/team-types"; + +export function InviteForm() { + const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState); + const [copied, setCopied] = useState(false); + const formRef = useRef(null); + + useEffect(() => { + if (state.ok && formRef.current) { + formRef.current.reset(); + } + }, [state.ok, state.shortUrl]); + + const copy = async () => { + if (!state.shortUrl) return; + try { + await navigator.clipboard.writeText(state.shortUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* ignore */ + } + }; + + return ( + + + Üye davet et + + Email ve rol girin, oluşturulan kısa linki kopyalayıp davet edeceğiniz kişiye gönderin. + + + + +
+ + +
+
+ + +
+
+ +
+ + + {state.error && ( +

+ {state.error} +

+ )} + + {state.ok && state.shortUrl && ( +
+ {state.message && ( +

{state.message}

+ )} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/members/components/members-table.tsx b/src/app/(dashboard)/settings/members/components/members-table.tsx new file mode 100644 index 0000000..5cd6dd5 --- /dev/null +++ b/src/app/(dashboard)/settings/members/components/members-table.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Loader2, Trash2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { removeMemberAction, updateMemberRoleAction } from "@/lib/appwrite/team-actions"; + +type Member = { + id: string; + userId: string; + name: string; + email: string; + role: string; + joined: string; + invited: string; + confirm: boolean; +}; + +const ROLE_LABEL: Record = { + owner: "Sahip", + admin: "Yönetici", + member: "Üye", +}; + +export function MembersTable({ + members, + currentUserId, + isOwner, + canManage, +}: { + members: Member[]; + currentUserId: string; + isOwner: boolean; + canManage: boolean; +}) { + const [busy, setBusy] = useState(null); + const [, startTransition] = useTransition(); + + const setRole = (membershipId: string, role: string) => { + setBusy(membershipId); + startTransition(async () => { + const fd = new FormData(); + fd.set("membershipId", membershipId); + fd.set("role", role); + await updateMemberRoleAction({ ok: false }, fd); + setBusy(null); + }); + }; + + const remove = (membershipId: string, name: string) => { + if (!confirm(`${name} adlı üyeyi çalışma alanından çıkarmak istediğinize emin misiniz?`)) { + return; + } + setBusy(membershipId); + startTransition(async () => { + const fd = new FormData(); + fd.set("membershipId", membershipId); + await removeMemberAction({ ok: false }, fd); + setBusy(null); + }); + }; + + return ( + + + Üyeler ({members.length}) + + + + + + İsim + Email + Rol + İşlem + + + + {members.map((m) => { + const isSelf = m.userId === currentUserId; + const isMemberOwner = m.role === "owner"; + return ( + + + {m.name} + {isSelf && ( + + Siz + + )} + + {m.email} + + {isOwner && !isMemberOwner && !isSelf ? ( + + ) : ( + + {ROLE_LABEL[m.role] ?? m.role} + + )} + + + {canManage && !isSelf && !isMemberOwner ? ( + + ) : null} + + + ); + })} + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/members/components/pending-invites-table.tsx b/src/app/(dashboard)/settings/members/components/pending-invites-table.tsx new file mode 100644 index 0000000..06090d9 --- /dev/null +++ b/src/app/(dashboard)/settings/members/components/pending-invites-table.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { Check, Copy, Loader2, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cancelInviteAction } from "@/lib/appwrite/team-actions"; + +type Invite = { + id: string; + code: string; + email: string; + role: string; + expiresAt?: string; + createdAt: string; +}; + +export function PendingInvitesTable({ + invites, + canManage, +}: { + invites: Invite[]; + canManage: boolean; +}) { + const [busy, setBusy] = useState(null); + const [, startTransition] = useTransition(); + const [copiedId, setCopiedId] = useState(null); + + const baseUrl = + typeof window !== "undefined" ? window.location.origin : ""; + + const copy = async (code: string, id: string) => { + try { + await navigator.clipboard.writeText(`${baseUrl}/d/${code}`); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + } catch { + /* ignore */ + } + }; + + const cancel = (id: string) => { + setBusy(id); + startTransition(async () => { + const fd = new FormData(); + fd.set("inviteId", id); + await cancelInviteAction({ ok: false }, fd); + setBusy(null); + }); + }; + + const formatDate = (iso?: string) => { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("tr-TR", { + day: "2-digit", + month: "short", + year: "numeric", + }); + }; + + return ( + + + Bekleyen davetler ({invites.length}) + + + + + + Email + Rol + Geçerlilik + İşlem + + + + {invites.map((inv) => ( + + {inv.email} + + + {inv.role === "admin" ? "Yönetici" : "Üye"} + + + + {formatDate(inv.expiresAt)} + + +
+ + {canManage && ( + + )} +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/members/page.tsx b/src/app/(dashboard)/settings/members/page.tsx new file mode 100644 index 0000000..7573a08 --- /dev/null +++ b/src/app/(dashboard)/settings/members/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Query } from "node-appwrite"; + +import { createAdminClient } from "@/lib/appwrite/server"; +import { DATABASE_ID, TABLES, type InviteLink } from "@/lib/appwrite/schema"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { InviteForm } from "./components/invite-form"; +import { MembersTable } from "./components/members-table"; +import { PendingInvitesTable } from "./components/pending-invites-table"; + +export const metadata: Metadata = { + title: "İşletmem — Ekip üyeleri", +}; + +export default async function MembersPage() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const canManage = ctx.role === "owner" || ctx.role === "admin"; + const isOwner = ctx.role === "owner"; + + const { teams, tablesDB } = createAdminClient(); + + const [memberships, invites] = await Promise.all([ + teams.listMemberships(ctx.tenantId).catch(() => ({ memberships: [], total: 0 })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.inviteLinks, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.equal("status", "pending"), + Query.orderDesc("$createdAt"), + Query.limit(50), + ], + }) + .catch(() => ({ rows: [] as unknown[] })), + ]); + + const members = memberships.memberships.map((m) => ({ + id: m.$id, + userId: m.userId, + name: m.userName || m.userEmail, + email: m.userEmail, + role: m.roles[0] ?? "member", + joined: m.joined, + invited: m.invited, + confirm: m.confirm, + })); + + const pendingInvites = (invites.rows as unknown as InviteLink[]).map((row) => ({ + id: row.$id, + code: row.code, + email: row.email, + role: row.role ?? "member", + expiresAt: row.expiresAt, + createdAt: row.$createdAt, + })); + + return ( +
+
+

{ctx.settings?.companyName ?? "Çalışma alanı"}

+

Ekip üyeleri

+

+ Çalışma alanına üye davet edin, rolleri yönetin. +

+
+ + {canManage ? ( + + ) : ( +

+ Yeni üye davet etmek için yönetici yetkisine ihtiyacınız var. +

+ )} + + {pendingInvites.length > 0 && ( + + )} + + +
+ ); +} diff --git a/src/app/d/[code]/accept-invite-button.tsx b/src/app/d/[code]/accept-invite-button.tsx new file mode 100644 index 0000000..97240f1 --- /dev/null +++ b/src/app/d/[code]/accept-invite-button.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { acceptInviteAction } from "@/lib/appwrite/team-actions"; +import { useState } from "react"; + +export function AcceptInviteButton({ code }: { code: string }) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const router = useRouter(); + + const handleAccept = () => { + setError(null); + startTransition(async () => { + const result = await acceptInviteAction(code); + if (result.ok) { + router.push("/dashboard"); + } else { + setError(result.error ?? "Beklenmeyen hata."); + } + }); + }; + + return ( + <> + + {error && ( +

+ {error} +

+ )} + + ); +} diff --git a/src/app/d/[code]/page.tsx b/src/app/d/[code]/page.tsx new file mode 100644 index 0000000..53d7ec5 --- /dev/null +++ b/src/app/d/[code]/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Logo } from "@/components/logo"; +import { resolveInviteCode } from "@/lib/appwrite/team-actions"; +import { createAdminClient, getCurrentUser } from "@/lib/appwrite/server"; +import { DATABASE_ID, TABLES, type TenantSettings } from "@/lib/appwrite/schema"; +import { AcceptInviteButton } from "./accept-invite-button"; +import { Query } from "node-appwrite"; + +export const metadata: Metadata = { + title: "İşletmem — Davet", +}; + +async function getCompanyName(tenantId: string): Promise { + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + return (result.rows[0] as unknown as TenantSettings | undefined)?.companyName ?? null; + } catch { + return null; + } +} + +export default async function InvitePage({ + params, +}: { + params: Promise<{ code: string }>; +}) { + const { code } = await params; + const invite = await resolveInviteCode(code); + const user = await getCurrentUser(); + + return ( +
+
+ +
+ +
+ İşletmem + + + {!invite || invite.status === "cancelled" ? ( + + ) : invite.status === "accepted" ? ( + + ) : invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now() ? ( + + ) : ( + + )} +
+
+ ); +} + +function InvalidCard({ reason, action }: { reason: string; action?: "dashboard" }) { + return ( + + + Davet kullanılamıyor + {reason} + + + + + + ); +} + +function ValidInvite({ + code, + email, + role, + companyName, + currentUserEmail, +}: { + code: string; + email: string; + role: string; + companyName: string; + currentUserEmail: string | null; +}) { + const roleLabel = role === "admin" ? "Yönetici" : "Üye"; + const emailMatches = currentUserEmail?.toLowerCase() === email.toLowerCase(); + + return ( + + + {companyName} + + {email} olarak {roleLabel} rolüyle çalışma alanına davet + edildiniz. + + + + {!currentUserEmail ? ( + <> + + + + ) : emailMatches ? ( + + ) : ( + <> +

+ Şu an {currentUserEmail} ile giriş yapmışsınız. Davet{" "} + {email} içindir. Doğru hesabı kullanın. +

+ + + )} +
+
+ ); +} diff --git a/src/lib/appwrite/audit.ts b/src/lib/appwrite/audit.ts new file mode 100644 index 0000000..5c5cf1a --- /dev/null +++ b/src/lib/appwrite/audit.ts @@ -0,0 +1,43 @@ +import "server-only"; + +import { headers } from "next/headers"; +import { ID, Permission, Role } from "node-appwrite"; + +import { createAdminClient } from "./server"; +import { DATABASE_ID, TABLES, type AuditAction } from "./schema"; + +export async function logAudit(args: { + tenantId: string; + userId: string; + action: AuditAction; + entityType: string; + entityId: string; + changes?: Record; +}) { + try { + const h = await headers(); + const ipAddress = + h.get("x-forwarded-for")?.split(",")[0]?.trim() || h.get("x-real-ip") || undefined; + const userAgent = h.get("user-agent")?.slice(0, 500) || undefined; + + const { tablesDB } = createAdminClient(); + await tablesDB.createRow( + DATABASE_ID, + TABLES.auditLogs, + ID.unique(), + { + tenantId: args.tenantId, + userId: args.userId, + action: args.action, + entityType: args.entityType, + entityId: args.entityId, + changes: args.changes ? JSON.stringify(args.changes).slice(0, 10000) : undefined, + ipAddress, + userAgent, + }, + [Permission.read(Role.team(args.tenantId))], + ); + } catch { + // audit failures must never block the user-facing operation + } +} diff --git a/src/lib/appwrite/auth-actions.ts b/src/lib/appwrite/auth-actions.ts index c0d347b..9dd5c7d 100644 --- a/src/lib/appwrite/auth-actions.ts +++ b/src/lib/appwrite/auth-actions.ts @@ -41,6 +41,7 @@ async function setSessionCookie(secret: string, expire: string) { export async function signInAction(_prev: AuthState, formData: FormData): Promise { const email = String(formData.get("email") ?? "").trim(); const password = String(formData.get("password") ?? ""); + const inviteCode = String(formData.get("inviteCode") ?? "").trim(); if (!email || !password) { return { ok: false, error: "Email ve şifre zorunlu." }; @@ -54,13 +55,14 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis return { ok: false, error: appwriteError(e) }; } - redirect("/dashboard"); + redirect(inviteCode ? `/d/${inviteCode}` : "/dashboard"); } export async function signUpAction(_prev: AuthState, formData: FormData): Promise { const name = String(formData.get("name") ?? "").trim(); const email = String(formData.get("email") ?? "").trim(); const password = String(formData.get("password") ?? ""); + const inviteCode = String(formData.get("inviteCode") ?? "").trim(); if (!name || !email || !password) { return { ok: false, error: "Tüm alanlar zorunlu." }; @@ -78,7 +80,7 @@ export async function signUpAction(_prev: AuthState, formData: FormData): Promis return { ok: false, error: appwriteError(e) }; } - redirect("/onboarding"); + redirect(inviteCode ? `/d/${inviteCode}` : "/onboarding"); } export async function forgotPasswordAction( diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index 16a0937..23fb23d 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -12,6 +12,7 @@ export const TABLES = { invoices: "invoices", invoiceItems: "invoice_items", auditLogs: "audit_logs", + inviteLinks: "invite_links", } as const; export type TableId = (typeof TABLES)[keyof typeof TABLES]; @@ -173,3 +174,18 @@ export interface AuditLog extends Row { ipAddress?: string; userAgent?: string; } + +export type InviteRole = "admin" | "member"; +export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired"; + +export interface InviteLink extends Row { + tenantId: string; + code: string; + email: string; + role?: InviteRole; + status?: InviteStatus; + invitedBy: string; + expiresAt?: string; + acceptedAt?: string; + acceptedBy?: string; +} diff --git a/src/lib/appwrite/team-actions.ts b/src/lib/appwrite/team-actions.ts new file mode 100644 index 0000000..a4a0527 --- /dev/null +++ b/src/lib/appwrite/team-actions.ts @@ -0,0 +1,377 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; + +import { logAudit } from "./audit"; +import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema"; +import { createAdminClient, createSessionClient } from "./server"; +import { requireRole, requireTenant } from "./tenant-guard"; +import type { InviteState, MemberActionState } from "./team-types"; + +const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; +const INVITE_TTL_DAYS = 7; + +function appwriteError(e: unknown): string { + if (e instanceof AppwriteException) { + if (e.type === "team_invite_already_exists") return "Bu kişi zaten davetli."; + if (e.type === "team_membership_already_confirmed") return "Bu kişi zaten ekipte."; + if (e.type === "user_not_found") return "Kullanıcı bulunamadı."; + return e.message || "Beklenmeyen bir hata oluştu."; + } + return "Bağlantı hatası. Tekrar deneyin."; +} + +function generateCode(length = 8): string { + const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let out = ""; + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + for (let i = 0; i < length; i++) out += chars[bytes[i] % chars.length]; + return out; +} + +async function findUserByEmail(email: string) { + const { users } = createAdminClient(); + const result = await users.list({ + queries: [Query.equal("email", email), Query.limit(1)], + }); + return result.users[0] ?? null; +} + +export async function inviteMemberAction( + _prev: InviteState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + const role = (String(formData.get("role") ?? "member") as InviteRole) === "admin" + ? "admin" + : "member"; + + if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + return { ok: false, error: "Geçerli bir email girin." }; + } + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + } catch { + return { ok: false, error: "Üye davet etme yetkiniz yok." }; + } + + if (email === ctx.user.email.toLowerCase()) { + return { ok: false, error: "Kendinizi davet edemezsiniz." }; + } + + const admin = createAdminClient(); + + // 1. Kullanıcı zaten Appwrite'ta var mı? + let existingUser = null; + try { + existingUser = await findUserByEmail(email); + } catch { + /* ignore */ + } + + // 2. Zaten ekipte mi? + if (existingUser) { + try { + const memberships = await admin.teams.listMemberships(ctx.tenantId); + if (memberships.memberships.some((m) => m.userId === existingUser!.$id)) { + return { ok: false, error: "Bu kullanıcı zaten ekipte." }; + } + } catch { + /* ignore */ + } + } + + // 3. Pending davet var mı? + try { + const existing = await admin.tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.inviteLinks, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.equal("email", email), + Query.equal("status", "pending"), + Query.limit(1), + ], + }); + const found = existing.rows[0] as unknown as InviteLink | undefined; + if (found) { + return { + ok: true, + shortUrl: `${APP_URL}/d/${found.code}`, + message: "Bu email için zaten aktif bir davet var. Mevcut linki kopyaladık.", + }; + } + } catch { + /* continue */ + } + + // 4. Yeni davet linki üret + const code = generateCode(8); + const expiresAt = new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString(); + + try { + const row = await admin.tablesDB.createRow( + DATABASE_ID, + TABLES.inviteLinks, + ID.unique(), + { + tenantId: ctx.tenantId, + code, + email, + role, + status: "pending", + invitedBy: ctx.user.id, + expiresAt, + }, + [ + Permission.read(Role.team(ctx.tenantId)), + Permission.update(Role.team(ctx.tenantId, "owner")), + Permission.update(Role.team(ctx.tenantId, "admin")), + Permission.delete(Role.team(ctx.tenantId, "owner")), + Permission.delete(Role.team(ctx.tenantId, "admin")), + ], + ); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "create", + entityType: "invite", + entityId: row.$id, + changes: { email, role }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/settings/members"); + return { + ok: true, + shortUrl: `${APP_URL}/d/${code}`, + message: existingUser + ? "Davet linki oluşturuldu. Linki bu kişiye iletin; tıklayınca giriş yapıp katılabilir." + : "Davet linki oluşturuldu. Linki bu kişiye iletin; tıklayınca hesap açıp katılabilir.", + }; +} + +export async function cancelInviteAction( + _prev: MemberActionState, + formData: FormData, +): Promise { + const inviteId = String(formData.get("inviteId") ?? ""); + if (!inviteId) return { ok: false, error: "Davet bulunamadı." }; + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + try { + const { tablesDB } = createAdminClient(); + await tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, inviteId, { + status: "cancelled", + }); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "invite", + entityId: inviteId, + changes: { status: "cancelled" }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/settings/members"); + return { ok: true }; +} + +export async function removeMemberAction( + _prev: MemberActionState, + formData: FormData, +): Promise { + const membershipId = String(formData.get("membershipId") ?? ""); + if (!membershipId) return { ok: false, error: "Üyelik bulunamadı." }; + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + try { + const { teams } = createAdminClient(); + const memberships = await teams.listMemberships(ctx.tenantId); + const target = memberships.memberships.find((m) => m.$id === membershipId); + + if (!target) return { ok: false, error: "Üyelik bulunamadı." }; + if (target.userId === ctx.user.id) { + return { ok: false, error: "Kendinizi çıkaramazsınız." }; + } + if (target.roles.includes("owner") && ctx.role !== "owner") { + return { ok: false, error: "Sahibi yalnızca başka bir sahip kaldırabilir." }; + } + + await teams.deleteMembership(ctx.tenantId, membershipId); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "delete", + entityType: "membership", + entityId: membershipId, + changes: { userEmail: target.userEmail }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/settings/members"); + return { ok: true }; +} + +export async function updateMemberRoleAction( + _prev: MemberActionState, + formData: FormData, +): Promise { + const membershipId = String(formData.get("membershipId") ?? ""); + const role = String(formData.get("role") ?? ""); + if (!membershipId || !["admin", "member"].includes(role)) { + return { ok: false, error: "Geçersiz parametre." }; + } + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner"]); + } catch { + return { ok: false, error: "Sadece sahibi rol değiştirebilir." }; + } + + try { + const { teams } = createAdminClient(); + await teams.updateMembership(ctx.tenantId, membershipId, [role]); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "membership", + entityId: membershipId, + changes: { role }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/settings/members"); + return { ok: true }; +} + +/** + * Resolve a short invite code to its row. Used by /d/[code] (public-ish). + * Returns minimal info; callers should not pass full row to client. + */ +export async function resolveInviteCode(code: string): Promise { + if (!code) return null; + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.inviteLinks, + queries: [Query.equal("code", code), Query.limit(1)], + }); + return (result.rows[0] as unknown as InviteLink) ?? null; + } catch { + return null; + } +} + +/** + * Accept an invite for the currently logged-in user. Caller is responsible + * for ensuring session.email matches invite.email (we double-check here too). + */ +export async function acceptInviteAction(code: string): Promise { + if (!code) return { ok: false, error: "Geçersiz davet linki." }; + + let user; + try { + const session = await createSessionClient(); + user = await session.account.get(); + } catch { + return { ok: false, error: "Önce giriş yapmanız gerekiyor." }; + } + + const invite = await resolveInviteCode(code); + if (!invite) return { ok: false, error: "Davet bulunamadı." }; + if (invite.status !== "pending") { + return { ok: false, error: "Bu davet artık geçerli değil." }; + } + if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) { + return { ok: false, error: "Bu davetin süresi dolmuş." }; + } + if (invite.email.toLowerCase() !== user.email.toLowerCase()) { + return { + ok: false, + error: `Bu davet ${invite.email} adresine gönderilmiş; mevcut hesabınız (${user.email}) eşleşmiyor.`, + }; + } + + const admin = createAdminClient(); + + // Already a member? Mark accepted, redirect. + try { + const memberships = await admin.teams.listMemberships(invite.tenantId); + const existing = memberships.memberships.find((m) => m.userId === user.$id); + if (existing) { + await admin.tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, invite.$id, { + status: "accepted", + acceptedAt: new Date().toISOString(), + acceptedBy: user.$id, + }); + return { ok: true }; + } + } catch { + /* ignore */ + } + + try { + await admin.teams.createMembership( + invite.tenantId, + [invite.role ?? "member"], + undefined, + user.$id, + ); + + await admin.tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, invite.$id, { + status: "accepted", + acceptedAt: new Date().toISOString(), + acceptedBy: user.$id, + }); + + await logAudit({ + tenantId: invite.tenantId, + userId: user.$id, + action: "create", + entityType: "membership", + entityId: invite.$id, + changes: { via: "invite", code }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + return { ok: true }; +} diff --git a/src/lib/appwrite/team-types.ts b/src/lib/appwrite/team-types.ts new file mode 100644 index 0000000..9aa5d58 --- /dev/null +++ b/src/lib/appwrite/team-types.ts @@ -0,0 +1,15 @@ +export type InviteState = { + ok: boolean; + error?: string; + shortUrl?: string; + message?: string; +}; + +export const initialInviteState: InviteState = { ok: false }; + +export type MemberActionState = { + ok: boolean; + error?: string; +}; + +export const initialMemberState: MemberActionState = { ok: false }; diff --git a/src/lib/appwrite/tenant-guard.ts b/src/lib/appwrite/tenant-guard.ts new file mode 100644 index 0000000..893be93 --- /dev/null +++ b/src/lib/appwrite/tenant-guard.ts @@ -0,0 +1,64 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { createAdminClient, getCurrentUser } from "./server"; +import { DATABASE_ID, TABLES, type TenantSettings } from "./schema"; +import { getActiveTenantId } from "./tenant"; + +export type TenantRole = "owner" | "admin" | "member"; + +export type TenantContext = { + user: { id: string; name: string; email: string }; + tenantId: string; + role: TenantRole; + settings: TenantSettings | null; +}; + +function pickHighestRole(roles: string[]): TenantRole | null { + if (roles.includes("owner")) return "owner"; + if (roles.includes("admin")) return "admin"; + if (roles.includes("member")) return "member"; + return null; +} + +export async function requireTenant(): Promise { + const user = await getCurrentUser(); + if (!user) throw new Error("UNAUTHENTICATED"); + + const tenantId = await getActiveTenantId(); + if (!tenantId) throw new Error("NO_TENANT"); + + const { tablesDB, teams } = createAdminClient(); + + const memberships = await teams.listMemberships(tenantId); + const membership = memberships.memberships.find((m) => m.userId === user.$id); + if (!membership) throw new Error("NOT_A_MEMBER"); + + const role = pickHighestRole(membership.roles) ?? "member"; + + let settings: TenantSettings | null = null; + try { + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + settings = (result.rows[0] as unknown as TenantSettings) ?? null; + } catch { + settings = null; + } + + return { + user: { id: user.$id, name: user.name, email: user.email }, + tenantId, + role, + settings, + }; +} + +export function requireRole(ctx: TenantContext, allowed: TenantRole[]): void { + if (!allowed.includes(ctx.role)) { + throw new Error("FORBIDDEN"); + } +}