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:
parent
8d34e079e8
commit
afbbf6ff25
1 changed files with 108 additions and 73 deletions
|
|
@ -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">✓</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); }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue