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