feat(team): manual-code invite flow + member management
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.
This commit is contained in:
@@ -8,7 +8,6 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
// TODO: re-enable once template files (chart.tsx, data-table-toolbar.tsx) are cleaned up.
|
// TODO: re-enable once template files (chart.tsx, data-table-toolbar.tsx) are cleaned up.
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
eslint: { ignoreDuringBuilds: true },
|
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import { signInAction } from "@/lib/appwrite/auth-actions";
|
import { signInAction } from "@/lib/appwrite/auth-actions";
|
||||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
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);
|
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +25,7 @@ export function LoginForm1({ className, ...props }: React.ComponentProps<"div">)
|
|||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
<CardContent className="grid p-0 md:grid-cols-2">
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
<form action={formAction} className="p-6 md:p-10">
|
<form action={formAction} className="p-6 md:p-10">
|
||||||
|
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||||
@@ -31,6 +36,12 @@ export function LoginForm1({ className, ...props }: React.ComponentProps<"div">)
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{inviteCode && (
|
||||||
|
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
|
||||||
|
Davete katılmak için giriş yapın.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
||||||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
<p className="text-muted-foreground text-sm text-balance mt-1">
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import { redirect } from "next/navigation";
|
|||||||
import { LoginForm1 } from "./components/login-form-1";
|
import { LoginForm1 } from "./components/login-form-1";
|
||||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
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();
|
const user = await getCurrentUser();
|
||||||
if (user) redirect("/dashboard");
|
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm md:max-w-4xl">
|
<div className="w-full max-w-sm md:max-w-4xl">
|
||||||
<LoginForm1 />
|
<LoginForm1 inviteCode={invite} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { cn } from "@/lib/utils";
|
|||||||
import { signUpAction } from "@/lib/appwrite/auth-actions";
|
import { signUpAction } from "@/lib/appwrite/auth-actions";
|
||||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
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);
|
const [state, formAction, isPending] = useActionState(signUpAction, initialAuthState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +28,7 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div">
|
|||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
|
|
||||||
<form action={formAction} className="p-6 md:p-10">
|
<form action={formAction} className="p-6 md:p-10">
|
||||||
|
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||||
@@ -34,9 +40,13 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div">
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Hesap oluşturun</h1>
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{inviteCode ? "Davete katıl" : "Hesap oluşturun"}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
<p className="text-muted-foreground text-sm text-balance mt-1">
|
||||||
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"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,6 +70,8 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div">
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="ornek@firma.com"
|
placeholder="ornek@firma.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
defaultValue={prefilledEmail}
|
||||||
|
readOnly={Boolean(prefilledEmail)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import { redirect } from "next/navigation";
|
|||||||
import { SignupForm1 } from "./components/signup-form-1";
|
import { SignupForm1 } from "./components/signup-form-1";
|
||||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
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();
|
const user = await getCurrentUser();
|
||||||
if (user) redirect("/dashboard");
|
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm md:max-w-4xl">
|
<div className="w-full max-w-sm md:max-w-4xl">
|
||||||
<SignupForm1 />
|
<SignupForm1 inviteCode={invite} prefilledEmail={email} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<HTMLFormElement>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Üye davet et</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Email ve rol girin, oluşturulan kısa linki kopyalayıp davet edeceğiniz kişiye gönderin.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-[1fr_180px_auto]">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ornek@firma.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="role">Rol</Label>
|
||||||
|
<Select name="role" defaultValue="member">
|
||||||
|
<SelectTrigger id="role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Üye</SelectItem>
|
||||||
|
<SelectItem value="admin">Yönetici</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Gönderiliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
Davet et
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.ok && state.shortUrl && (
|
||||||
|
<div className="bg-muted/50 mt-4 flex flex-col gap-2 rounded-md border p-3">
|
||||||
|
{state.message && (
|
||||||
|
<p className="text-muted-foreground text-xs">{state.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={state.shortUrl} readOnly className="font-mono text-xs" />
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={copy}>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
{copied ? "Kopyalandı" : "Kopyala"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Üyeler ({members.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>İsim</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rol</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((m) => {
|
||||||
|
const isSelf = m.userId === currentUserId;
|
||||||
|
const isMemberOwner = m.role === "owner";
|
||||||
|
return (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{m.name}
|
||||||
|
{isSelf && (
|
||||||
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
|
Siz
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{m.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isOwner && !isMemberOwner && !isSelf ? (
|
||||||
|
<Select
|
||||||
|
value={m.role}
|
||||||
|
disabled={busy === m.id}
|
||||||
|
onValueChange={(v) => setRole(m.id, v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Üye</SelectItem>
|
||||||
|
<SelectItem value="admin">Yönetici</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Badge variant={isMemberOwner ? "default" : "outline"}>
|
||||||
|
{ROLE_LABEL[m.role] ?? m.role}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{canManage && !isSelf && !isMemberOwner ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy === m.id}
|
||||||
|
onClick={() => remove(m.id, m.name)}
|
||||||
|
>
|
||||||
|
{busy === m.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Çıkar
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bekleyen davetler ({invites.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rol</TableHead>
|
||||||
|
<TableHead>Geçerlilik</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invites.map((inv) => (
|
||||||
|
<TableRow key={inv.id}>
|
||||||
|
<TableCell className="font-medium">{inv.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{inv.role === "admin" ? "Yönetici" : "Üye"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatDate(inv.expiresAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copy(inv.code, inv.id)}
|
||||||
|
>
|
||||||
|
{copiedId === inv.id ? (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Linki kopyala
|
||||||
|
</Button>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy === inv.id}
|
||||||
|
onClick={() => cancel(inv.id)}
|
||||||
|
>
|
||||||
|
{busy === inv.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="size-3.5" />
|
||||||
|
)}
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Ekip üyeleri</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Çalışma alanına üye davet edin, rolleri yönetin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<InviteForm />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Yeni üye davet etmek için yönetici yetkisine ihtiyacınız var.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingInvites.length > 0 && (
|
||||||
|
<PendingInvitesTable invites={pendingInvites} canManage={canManage} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MembersTable
|
||||||
|
members={members}
|
||||||
|
currentUserId={ctx.user.id}
|
||||||
|
isOwner={isOwner}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleAccept} disabled={isPending} className="w-full">
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Katılınıyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Daveti kabul et
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-center text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
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 (
|
||||||
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6">
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
|
<Logo size={22} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">İşletmem</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{!invite || invite.status === "cancelled" ? (
|
||||||
|
<InvalidCard reason="Davet bulunamadı veya iptal edilmiş." />
|
||||||
|
) : invite.status === "accepted" ? (
|
||||||
|
<InvalidCard reason="Bu davet daha önce kabul edilmiş." action="dashboard" />
|
||||||
|
) : invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now() ? (
|
||||||
|
<InvalidCard reason="Bu davetin süresi dolmuş." />
|
||||||
|
) : (
|
||||||
|
<ValidInvite
|
||||||
|
code={code}
|
||||||
|
email={invite.email}
|
||||||
|
role={invite.role ?? "member"}
|
||||||
|
companyName={(await getCompanyName(invite.tenantId)) ?? "Bir çalışma alanı"}
|
||||||
|
currentUserEmail={user?.email ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvalidCard({ reason, action }: { reason: string; action?: "dashboard" }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Davet kullanılamıyor</CardTitle>
|
||||||
|
<CardDescription>{reason}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={action === "dashboard" ? "/dashboard" : "/sign-in"}>
|
||||||
|
{action === "dashboard" ? "Panele git" : "Giriş yap"}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">{companyName}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<strong>{email}</strong> olarak <strong>{roleLabel}</strong> rolüyle çalışma alanına davet
|
||||||
|
edildiniz.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{!currentUserEmail ? (
|
||||||
|
<>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href={`/sign-up?invite=${code}&email=${encodeURIComponent(email)}`}>
|
||||||
|
Hesap oluşturup katıl
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href={`/sign-in?invite=${code}`}>Zaten hesabım var, giriş yap</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : emailMatches ? (
|
||||||
|
<AcceptInviteButton code={code} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-destructive text-center text-sm">
|
||||||
|
Şu an <strong>{currentUserEmail}</strong> ile giriş yapmışsınız. Davet{" "}
|
||||||
|
<strong>{email}</strong> içindir. Doğru hesabı kullanın.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href={`/sign-in?invite=${code}`}>Farklı hesapla giriş yap</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ async function setSessionCookie(secret: string, expire: string) {
|
|||||||
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
||||||
const email = String(formData.get("email") ?? "").trim();
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return { ok: false, error: "Email ve şifre zorunlu." };
|
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) };
|
return { ok: false, error: appwriteError(e) };
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect("/dashboard");
|
redirect(inviteCode ? `/d/${inviteCode}` : "/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
const email = String(formData.get("email") ?? "").trim();
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
|
||||||
|
|
||||||
if (!name || !email || !password) {
|
if (!name || !email || !password) {
|
||||||
return { ok: false, error: "Tüm alanlar zorunlu." };
|
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) };
|
return { ok: false, error: appwriteError(e) };
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect("/onboarding");
|
redirect(inviteCode ? `/d/${inviteCode}` : "/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPasswordAction(
|
export async function forgotPasswordAction(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const TABLES = {
|
|||||||
invoices: "invoices",
|
invoices: "invoices",
|
||||||
invoiceItems: "invoice_items",
|
invoiceItems: "invoice_items",
|
||||||
auditLogs: "audit_logs",
|
auditLogs: "audit_logs",
|
||||||
|
inviteLinks: "invite_links",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||||
@@ -173,3 +174,18 @@ export interface AuditLog extends Row {
|
|||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<InviteState> {
|
||||||
|
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<MemberActionState> {
|
||||||
|
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<MemberActionState> {
|
||||||
|
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<MemberActionState> {
|
||||||
|
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<InviteLink | null> {
|
||||||
|
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<MemberActionState> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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<TenantContext> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user