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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user