gitdataai/src/app/project/settings/MembersSettings.tsx
ZhenYi 0491b668c7 feat(project): add member invitation to project creation modal
- Add email invite flow with role selection to ProjectCreateMenuModal
- Update MembersSettings with avatar URL support
- Adjust ProjectSettingsLayout layout for new content
2026-05-15 13:11:01 +08:00

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>
);
}