feat(settings): redesign members tab with dropdown menu

Replace inline select + delete button with a three-dot dropdown menu
per member. Adds role descriptions, owner self-demotion protection, and
a cleaner list layout with ring border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-29 17:42:59 +08:00
parent 8d34e079e8
commit afbbf6ff25

View file

@ -1,12 +1,12 @@
"use client";
import { useState } from "react";
import { Crown, Shield, User, Plus, Trash2, Users } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogContent,
@ -24,15 +24,25 @@ import {
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
admin: { label: "Admin", icon: Shield },
member: { label: "Member", icon: User },
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
owner: { label: "Owner", icon: Crown, description: "Full access, manage all settings" },
admin: { label: "Admin", icon: Shield, description: "Manage members and settings" },
member: { label: "Member", icon: User, description: "Create and work on issues" },
};
function MemberRow({
@ -54,59 +64,82 @@ function MemberRow({
}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
const canEditRole = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
const showMenu = canEditRole || canRemove;
return (
<Card>
<CardContent className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
</div>
{canEditRole ? (
<Select value={member.role} onValueChange={(value) => onRoleChange(value as MemberRole)} disabled={busy}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{canManageOwners && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
)}
{canRemove && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={busy}
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Remove member</TooltipContent>
</Tooltip>
)}
</CardContent>
</Card>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
</div>
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" disabled={busy}>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
{canEditRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Shield className="h-3.5 w-3.5" />
Change role
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto">
{(Object.entries(roleConfig) as [MemberRole, (typeof roleConfig)[MemberRole]][]).map(
([role, config]) => {
if (role === "owner" && !canManageOwners) return null;
const Icon = config.icon;
return (
<DropdownMenuItem
key={role}
onClick={() => onRoleChange(role)}
>
<Icon className="h-3.5 w-3.5" />
<div className="flex flex-col">
<span>{config.label}</span>
<span className="text-xs text-muted-foreground font-normal">
{config.description}
</span>
</div>
{member.role === role && (
<span className="ml-auto text-xs text-muted-foreground">&#10003;</span>
)}
</DropdownMenuItem>
);
}
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{canEditRole && canRemove && <DropdownMenuSeparator />}
{canRemove && (
<DropdownMenuItem variant="destructive" onClick={onRemove}>
<UserMinus className="h-3.5 w-3.5" />
Remove from workspace
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<Badge variant="secondary">
<RoleIcon className="h-3 w-3" />
{rc.label}
</Badge>
</div>
);
}
@ -228,23 +261,25 @@ export function MembersTab() {
</Card>
)}
<div className="space-y-2">
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
{members.length > 0 ? (
<div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
{members.map((m, i) => (
<div key={m.id} className={i > 0 ? "border-t border-border/50" : ""}>
<MemberRow
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>