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:
@@ -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,108 +65,222 @@ 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>
|
||||
</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">
|
||||
{canManage && !isSelf && !isMemberOwner ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy === m.id}
|
||||
onClick={() => remove(m.id, m.name)}
|
||||
>
|
||||
{busy === m.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
Çıkar
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user