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";
|
"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,34 +65,59 @@ 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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Üyeler ({members.length})</CardTitle>
|
<CardTitle>Üyeler ({members.length})</CardTitle>
|
||||||
@@ -130,13 +169,30 @@ export function MembersTable({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{canManage && !isSelf && !isMemberOwner ? (
|
{isSelf ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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}
|
disabled={busy === m.id}
|
||||||
onClick={() => remove(m.id, m.name)}
|
onClick={() => setRemoving(m)}
|
||||||
>
|
>
|
||||||
{busy === m.id ? (
|
{busy === m.id ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
@@ -154,5 +210,77 @@ export function MembersTable({
|
|||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 };
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user