- Add email invite flow with role selection to ProjectCreateMenuModal - Update MembersSettings with avatar URL support - Adjust ProjectSettingsLayout layout for new content
102 lines
5.7 KiB
TypeScript
102 lines
5.7 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { projectMembersGrouped, projectUpdateMemberRole, projectRemoveMember } from "@/client/api";
|
|
import type { GroupedMemberListResponse, MemberInfo, MemberRole } from "@/client/model";
|
|
import { useProjectInfo } from "@/hooks/useProjectInfo";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Loader2 } from "lucide-react";
|
|
import { isProjectAdminRole, isProjectOwnerRole } from "@/lib/project-permissions";
|
|
|
|
const NEXT_ROLE: Record<string, string> = { admin: "member", member: "admin" };
|
|
|
|
export function MembersSettings() {
|
|
const { projectName } = useParams<{ projectName: string }>();
|
|
const { data: projectInfo } = useProjectInfo(projectName);
|
|
const queryClient = useQueryClient();
|
|
const isCurrentAdmin = isProjectAdminRole(projectInfo?.role);
|
|
const isCurrentOwner = isProjectOwnerRole(projectInfo?.role);
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["project-members-grouped", projectName],
|
|
queryFn: async () => {
|
|
const res = await projectMembersGrouped(projectName!);
|
|
return res.data?.data as GroupedMemberListResponse;
|
|
},
|
|
enabled: !!projectName,
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
|
|
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="w-5 h-5 animate-spin" style={{ color: "var(--text-muted)" }} /></div>;
|
|
if (error || !data) return <div className="text-center py-10" style={{ color: "var(--destructive)" }}>Failed to load members</div>;
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["project-members-grouped", projectName] });
|
|
|
|
const handleRoleToggle = async (member: MemberInfo) => {
|
|
const nextRole = NEXT_ROLE[member.scope.toLowerCase()] as MemberRole;
|
|
if (!nextRole) return;
|
|
try { setActionLoading(member.user_id); setMsg(null); await projectUpdateMemberRole(projectName!, { user_id: member.user_id, scope: nextRole }); setMsg({ type: "success", text: `${member.username} role changed to ${nextRole}` }); invalidate(); }
|
|
catch { setMsg({ type: "error", text: "Failed to update role" }); }
|
|
finally { setActionLoading(null); }
|
|
};
|
|
|
|
const handleRemove = async (member: MemberInfo) => {
|
|
if (!confirm(`Remove ${member.username} from this project?`)) return;
|
|
try { setActionLoading(member.user_id); setMsg(null); await projectRemoveMember(projectName!, member.user_id); setMsg({ type: "success", text: `${member.username} removed` }); invalidate(); }
|
|
catch { setMsg({ type: "error", text: "Failed to remove member" }); }
|
|
finally { setActionLoading(null); }
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{msg && <div className="mb-4 text-[13px]" style={{ color: msg.type === "success" ? "var(--success)" : "var(--destructive)" }}>{msg.text}</div>}
|
|
|
|
{data.groups.map(group => (
|
|
<div key={group.role} className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
|
|
{group.role}s — {group.members.length}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1 rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-default)" }}>
|
|
{group.members.map(member => {
|
|
const isSelf = member.user_id === projectInfo?.created_by;
|
|
const canManage = isCurrentAdmin && !isProjectOwnerRole(member.scope) && !isSelf;
|
|
|
|
return (
|
|
<div key={member.user_id} className="flex items-center gap-3 px-4 py-3" style={{ backgroundColor: "var(--surface-elevated)", borderBottom: "1px solid var(--border-subtle)" }}>
|
|
<Avatar className="w-8 h-8 rounded-full">
|
|
<AvatarImage src={member.avatar_url || undefined} />
|
|
<AvatarFallback style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>{(member.display_name || member.username)[0]?.toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[14px] font-medium truncate" style={{ color: "var(--text-primary)" }}>{member.display_name || member.username}</p>
|
|
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>@{member.username}</p>
|
|
</div>
|
|
{isSelf && <span className="text-[11px] px-2 py-0.5 rounded" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-muted)" }}>You</span>}
|
|
<div className="flex items-center gap-2">
|
|
{canManage && (
|
|
<Button size="sm" variant="ghost" className="h-7 text-[12px]" onClick={() => handleRoleToggle(member)} disabled={actionLoading === member.user_id}>
|
|
{actionLoading === member.user_id ? <Loader2 className="w-3 h-3 animate-spin" /> : `Make ${NEXT_ROLE[member.scope.toLowerCase()] || member.scope}`}
|
|
</Button>
|
|
)}
|
|
{canManage && isCurrentOwner && (
|
|
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--destructive)" }} onClick={() => handleRemove(member)} disabled={actionLoading === member.user_id}>
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|