Files
isletmem-kovakcrm/src/lib/appwrite/team-actions.ts
T
kovakmedya 643f2de29b 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.
2026-04-30 05:34:47 +03:00

378 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
}