Files
isletmem-kovakcrm/src/app/(dashboard)/settings/members/components/members-table.tsx
T
kovakmedya ab336b191f 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.
2026-04-30 08:41:52 +03:00

287 lines
8.8 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 client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
leaveWorkspaceAction,
removeMemberAction,
updateMemberRoleAction,
} from "@/lib/appwrite/team-actions";
type Member = {
id: string;
userId: string;
name: string;
email: string;
role: string;
joined: string;
invited: string;
confirm: boolean;
};
const ROLE_LABEL: Record<string, string> = {
owner: "Sahip",
admin: "Yönetici",
member: "Üye",
};
export function MembersTable({
members,
currentUserId,
isOwner,
canManage,
}: {
members: Member[];
currentUserId: string;
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 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 handleRemove = () => {
if (!removing) return;
setBusy(removing.id);
startTransition(async () => {
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>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>İsim</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rol</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isMemberOwner = m.role === "owner";
return (
<TableRow key={m.id}>
<TableCell className="font-medium">
{m.name}
{isSelf && (
<Badge variant="secondary" className="ml-2 text-xs">
Siz
</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{m.email}</TableCell>
<TableCell>
{isOwner && !isMemberOwner && !isSelf ? (
<Select
value={m.role}
disabled={busy === m.id}
onValueChange={(v) => setRole(m.id, v)}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Üye</SelectItem>
<SelectItem value="admin">Yönetici</SelectItem>
</SelectContent>
</Select>
) : (
<Badge variant={isMemberOwner ? "default" : "outline"}>
{ROLE_LABEL[m.role] ?? m.role}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{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={() => 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;
}