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:
kovakmedya
2026-04-30 05:34:47 +03:00
parent 84e5f20afd
commit 643f2de29b
17 changed files with 1258 additions and 13 deletions
@@ -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">)
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<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 justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
@@ -31,6 +36,12 @@ export function LoginForm1({ className, ...props }: React.ComponentProps<"div">)
</Link>
</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">
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
+8 -3
View File
@@ -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 (
<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">
<LoginForm1 />
<LoginForm1 inviteCode={invite} />
</div>
</div>
);
@@ -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">
<BrandPanel />
<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 justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
@@ -34,9 +40,13 @@ export function SignupForm1({ className, ...props }: React.ComponentProps<"div">
</div>
<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">
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>
</div>
@@ -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
/>
</div>
+8 -3
View File
@@ -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 (
<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">
<SignupForm1 />
<SignupForm1 inviteCode={invite} prefilledEmail={email} />
</div>
</div>
);