ab336b191f
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.
287 lines
8.8 KiB
TypeScript
287 lines
8.8 KiB
TypeScript
"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;
|
||
}
|