feat(team): proper member removal + self-leave flow

Member-management UX cleanup:

- Replaced window.confirm() with shadcn Dialog confirmation (matches
  every other destructive action in the app).
- Toast feedback on success/error for both removal and role updates —
  before, errors from the server (örn. 'Sahibi yalnızca başka bir sahip
  kaldırabilir') were swallowed.
- New 'Ayrıl' (leave) button on the current user's own row — previously
  there was no way for a member to leave a workspace except by being
  removed by an admin.

Server (lib/appwrite/team-actions.ts):
- New leaveWorkspaceAction:
  * Refuses if the caller is the only owner (would leave the workspace
    ownerless).
  * Calls teams.deleteMembership for the caller's own membership.
  * Clears account.prefs.activeTenant + isletmem-tenant cookie so the
    next request goes to fallback tenant or onboarding.
  * Audit-logged with self:true marker.
- removeMemberAction unchanged (already had owner-only-can-remove-owner
  + can't-remove-self guards).

UI:
- Each row's action cell now shows: 'Ayrıl' (self) / 'Çıkar' (others, if
  canManage and target isn't owner) / nothing (the rest).
- Removal dialog explains data isn't deleted, just access revoked.
- Leave dialog warns the user about losing access.
- Both dialogs gate close-on-outside-click while a request is in flight.
This commit is contained in:
kovakmedya
2026-04-30 08:41:52 +03:00
parent 1f79abe404
commit ab336b191f
2 changed files with 285 additions and 89 deletions
@@ -1,11 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
@@ -21,7 +31,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { removeMemberAction, updateMemberRoleAction } from "@/lib/appwrite/team-actions";
import {
leaveWorkspaceAction,
removeMemberAction,
updateMemberRoleAction,
} from "@/lib/appwrite/team-actions";
type Member = {
id: string;
@@ -51,34 +65,59 @@ export function MembersTable({
isOwner: boolean;
canManage: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [removing, setRemoving] = useState<Member | null>(null);
const [leaving, setLeaving] = useState(false);
const setRole = (membershipId: string, role: string) => {
setBusy(membershipId);
startTransition(async () => {
const fd = new FormData();
fd.set("membershipId", membershipId);
fd.set("role", role);
await updateMemberRoleAction({ ok: false }, fd);
const result = await updateMemberRoleAction({ ok: false }, formDataFor({
membershipId,
role,
}));
if (result.ok) toast.success("Rol güncellendi.");
else toast.error(result.error ?? "Rol güncellenemedi.");
setBusy(null);
});
};
const remove = (membershipId: string, name: string) => {
if (!confirm(`${name} adlı üyeyi çalışma alanından çıkarmak istediğinize emin misiniz?`)) {
return;
}
setBusy(membershipId);
const handleRemove = () => {
if (!removing) return;
setBusy(removing.id);
startTransition(async () => {
const fd = new FormData();
fd.set("membershipId", membershipId);
await removeMemberAction({ ok: false }, fd);
const result = await removeMemberAction({ ok: false }, formDataFor({
membershipId: removing.id,
}));
if (result.ok) {
toast.success(`${removing.name} ekipten çıkarıldı.`);
setRemoving(null);
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
setBusy(null);
});
};
const handleLeave = () => {
setBusy("leave");
startTransition(async () => {
const result = await leaveWorkspaceAction();
if (result.ok) {
toast.success("Çalışma alanından ayrıldınız.");
setLeaving(false);
router.push("/dashboard");
} else {
toast.error(result.error ?? "Ayrılma başarısız.");
}
setBusy(null);
});
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Üyeler ({members.length})</CardTitle>
@@ -130,13 +169,30 @@ export function MembersTable({
)}
</TableCell>
<TableCell className="text-right">
{canManage && !isSelf && !isMemberOwner ? (
{isSelf ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
disabled={busy === "leave"}
onClick={() => setLeaving(true)}
>
{busy === "leave" ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<DoorOpen className="size-3.5" />
)}
Ayrıl
</Button>
) : canManage && !isMemberOwner ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy === m.id}
onClick={() => remove(m.id, m.name)}
onClick={() => setRemoving(m)}
>
{busy === m.id ? (
<Loader2 className="size-3.5 animate-spin" />
@@ -154,5 +210,77 @@ export function MembersTable({
</Table>
</CardContent>
</Card>
<Dialog
open={Boolean(removing)}
onOpenChange={(v) => !v && busy === null && setRemoving(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Üyeyi ekipten çıkar</DialogTitle>
<DialogDescription>
{removing && (
<>
<strong>{removing.name}</strong> ({removing.email}) ekipten çıkarılacak.
Verileri silinmez ama bu çalışma alanına erişimi kalkar.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRemoving(null)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Çıkar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={leaving} onOpenChange={(v) => !v && busy === null && setLeaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Çalışma alanından ayrıl</DialogTitle>
<DialogDescription>
Bu çalışma alanındaki tüm verilere erişiminiz kalkar. Tekrar davet edilmedikçe
giremezsiniz. Devam etmek istediğinize emin misiniz?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setLeaving(false)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleLeave}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
Ayrıl
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function formDataFor(fields: Record<string, string>): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
return fd;
}
+68
View File
@@ -244,6 +244,74 @@ export async function removeMemberAction(
return { ok: true };
}
/**
* Self-leave: the current user removes themselves from the active tenant.
* - If they're the only owner, the call is refused (would leave the workspace
* ownerless).
* - On success the active-tenant cookie + prefs.activeTenant are cleared so
* the next request goes through fallback to another team or onboarding.
*/
export async function leaveWorkspaceAction(): Promise<MemberActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const admin = createAdminClient();
const memberships = await admin.teams.listMemberships(ctx.tenantId);
const me = memberships.memberships.find((m) => m.userId === ctx.user.id);
if (!me) return { ok: false, error: "Üyelik bulunamadı." };
if (me.roles.includes("owner")) {
const otherOwners = memberships.memberships.filter(
(m) => m.userId !== ctx.user.id && m.roles.includes("owner"),
);
if (otherOwners.length === 0) {
return {
ok: false,
error:
"Tek sahip olduğunuz için ayrılamazsınız. Önce başka bir üyeyi sahip yapın.",
};
}
}
await admin.teams.deleteMembership(ctx.tenantId, me.$id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "membership",
entityId: me.$id,
changes: { self: true, userEmail: ctx.user.email },
});
// Clear active-tenant pointers so the user lands somewhere safe next request.
try {
const session = await createSessionClient();
const user = await session.account.get();
const prefs = { ...(user.prefs as Record<string, unknown>) };
delete prefs.activeTenant;
await session.account.updatePrefs(prefs);
} catch {
/* ignore */
}
try {
(await cookies()).delete(ACTIVE_TENANT_COOKIE);
} catch {
/* ignore */
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/", "layout");
return { ok: true };
}
export async function updateMemberRoleAction(
_prev: MemberActionState,
formData: FormData,