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
+377
View File
@@ -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 };
}