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"; "use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -21,7 +31,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { removeMemberAction, updateMemberRoleAction } from "@/lib/appwrite/team-actions"; import {
leaveWorkspaceAction,
removeMemberAction,
updateMemberRoleAction,
} from "@/lib/appwrite/team-actions";
type Member = { type Member = {
id: string; id: string;
@@ -51,108 +65,222 @@ export function MembersTable({
isOwner: boolean; isOwner: boolean;
canManage: boolean; canManage: boolean;
}) { }) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [removing, setRemoving] = useState<Member | null>(null);
const [leaving, setLeaving] = useState(false);
const setRole = (membershipId: string, role: string) => { const setRole = (membershipId: string, role: string) => {
setBusy(membershipId); setBusy(membershipId);
startTransition(async () => { startTransition(async () => {
const fd = new FormData(); const result = await updateMemberRoleAction({ ok: false }, formDataFor({
fd.set("membershipId", membershipId); membershipId,
fd.set("role", role); role,
await updateMemberRoleAction({ ok: false }, fd); }));
if (result.ok) toast.success("Rol güncellendi.");
else toast.error(result.error ?? "Rol güncellenemedi.");
setBusy(null); setBusy(null);
}); });
}; };
const remove = (membershipId: string, name: string) => { const handleRemove = () => {
if (!confirm(`${name} adlı üyeyi çalışma alanından çıkarmak istediğinize emin misiniz?`)) { if (!removing) return;
return; setBusy(removing.id);
}
setBusy(membershipId);
startTransition(async () => { startTransition(async () => {
const fd = new FormData(); const result = await removeMemberAction({ ok: false }, formDataFor({
fd.set("membershipId", membershipId); membershipId: removing.id,
await removeMemberAction({ ok: false }, fd); }));
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); setBusy(null);
}); });
}; };
return ( return (
<Card> <>
<CardHeader> <Card>
<CardTitle>Üyeler ({members.length})</CardTitle> <CardHeader>
</CardHeader> <CardTitle>Üyeler ({members.length})</CardTitle>
<CardContent> </CardHeader>
<Table> <CardContent>
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>İsim</TableHead> <TableRow>
<TableHead>Email</TableHead> <TableHead>İsim</TableHead>
<TableHead>Rol</TableHead> <TableHead>Email</TableHead>
<TableHead className="text-right">İşlem</TableHead> <TableHead>Rol</TableHead>
</TableRow> <TableHead className="text-right">İşlem</TableHead>
</TableHeader> </TableRow>
<TableBody> </TableHeader>
{members.map((m) => { <TableBody>
const isSelf = m.userId === currentUserId; {members.map((m) => {
const isMemberOwner = m.role === "owner"; const isSelf = m.userId === currentUserId;
return ( const isMemberOwner = m.role === "owner";
<TableRow key={m.id}> return (
<TableCell className="font-medium"> <TableRow key={m.id}>
{m.name} <TableCell className="font-medium">
{isSelf && ( {m.name}
<Badge variant="secondary" className="ml-2 text-xs"> {isSelf && (
Siz <Badge variant="secondary" className="ml-2 text-xs">
</Badge> Siz
)} </Badge>
</TableCell> )}
<TableCell className="text-muted-foreground">{m.email}</TableCell> </TableCell>
<TableCell> <TableCell className="text-muted-foreground">{m.email}</TableCell>
{isOwner && !isMemberOwner && !isSelf ? ( <TableCell>
<Select {isOwner && !isMemberOwner && !isSelf ? (
value={m.role} <Select
disabled={busy === m.id} value={m.role}
onValueChange={(v) => setRole(m.id, v)} disabled={busy === m.id}
> onValueChange={(v) => setRole(m.id, v)}
<SelectTrigger className="w-[120px]"> >
<SelectValue /> <SelectTrigger className="w-[120px]">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="member">Üye</SelectItem> <SelectContent>
<SelectItem value="admin">Yönetici</SelectItem> <SelectItem value="member">Üye</SelectItem>
</SelectContent> <SelectItem value="admin">Yönetici</SelectItem>
</Select> </SelectContent>
) : ( </Select>
<Badge variant={isMemberOwner ? "default" : "outline"}> ) : (
{ROLE_LABEL[m.role] ?? m.role} <Badge variant={isMemberOwner ? "default" : "outline"}>
</Badge> {ROLE_LABEL[m.role] ?? m.role}
)} </Badge>
</TableCell> )}
<TableCell className="text-right"> </TableCell>
{canManage && !isSelf && !isMemberOwner ? ( <TableCell className="text-right">
<Button {isSelf ? (
type="button" <Button
variant="ghost" type="button"
size="sm" variant="ghost"
disabled={busy === m.id} size="sm"
onClick={() => remove(m.id, m.name)} className="text-muted-foreground"
> disabled={busy === "leave"}
{busy === m.id ? ( onClick={() => setLeaving(true)}
<Loader2 className="size-3.5 animate-spin" /> >
) : ( {busy === "leave" ? (
<Trash2 className="size-3.5" /> <Loader2 className="size-3.5 animate-spin" />
)} ) : (
Çıkar <DoorOpen className="size-3.5" />
</Button> )}
) : null} Ayrıl
</TableCell> </Button>
</TableRow> ) : canManage && !isMemberOwner ? (
); <Button
})} type="button"
</TableBody> variant="ghost"
</Table> size="sm"
</CardContent> className="text-destructive hover:text-destructive"
</Card> disabled={busy === m.id}
onClick={() => setRemoving(m)}
>
{busy === m.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
Çıkar
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</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 }; 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( export async function updateMemberRoleAction(
_prev: MemberActionState, _prev: MemberActionState,
formData: FormData, formData: FormData,