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
This commit is contained in:
parent
7ec848470e
commit
0491b668c7
@ -4,6 +4,8 @@ import { useCreateRepoMutation } from "@/hooks/useReposQuery";
|
||||
import { useCreateRoomMutation } from "@/hooks/useRoomsQuery";
|
||||
import { useBoardOperations } from "@/hooks/useBoardOperations";
|
||||
import { useCreateSkillMutation } from "@/hooks/useSkillsQuery";
|
||||
import { projectInviteUser } from "@/client/api";
|
||||
import type { MemberRole } from "@/client/model";
|
||||
import {
|
||||
X,
|
||||
Hash,
|
||||
@ -15,23 +17,33 @@ import {
|
||||
FolderPlus,
|
||||
MessageSquarePlus,
|
||||
Kanban,
|
||||
Zap
|
||||
Zap,
|
||||
Mail,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface ProjectCreateMenuModalProps {
|
||||
onClose: () => void;
|
||||
initialTab?: "repo" | "channel" | "board" | "skill";
|
||||
initialTab?: "repo" | "channel" | "board" | "skill" | "invite";
|
||||
}
|
||||
|
||||
export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: ProjectCreateMenuModalProps) {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"repo" | "channel" | "board" | "skill">(initialTab);
|
||||
const [activeTab, setActiveTab] = useState<"repo" | "channel" | "board" | "skill" | "invite">(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -64,6 +76,12 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
});
|
||||
const createSkill = useCreateSkillMutation(projectName);
|
||||
|
||||
// --- Invite Form State ---
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: "",
|
||||
scope: "Member" as MemberRole,
|
||||
});
|
||||
|
||||
const handleCreateRepo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!repoForm.repo_name.trim()) return;
|
||||
@ -149,6 +167,25 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!projectName || !inviteForm.email.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await projectInviteUser(projectName, {
|
||||
email: inviteForm.email.trim(),
|
||||
scope: inviteForm.scope,
|
||||
});
|
||||
setInviteForm({ email: "", scope: "Member" });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to send invitation.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div
|
||||
@ -160,7 +197,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
<div className="px-1 pt-1 border-b" style={{ backgroundColor: "var(--surface-rail)", borderColor: "var(--border-default)" }}>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-[14px] font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PlusCircle className="w-4 h-4" style={{ color: "var(--accent)" }} /> Quick Create
|
||||
<PlusCircle className="w-4 h-4" style={{ color: "var(--accent)" }} /> Quick Start
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@ -176,6 +213,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
{ id: "channel", label: "Channel", icon: MessageSquarePlus },
|
||||
{ id: "board", label: "Board", icon: Kanban },
|
||||
{ id: "skill", label: "Skill", icon: Zap },
|
||||
{ id: "invite", label: "Invite", icon: UserPlus },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -381,8 +419,58 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === "invite" && (
|
||||
<form onSubmit={handleInviteUser} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>Invite by Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="user@example.com"
|
||||
value={inviteForm.email}
|
||||
onChange={e => setInviteForm({...inviteForm, email: e.target.value})}
|
||||
className="border-none h-10 pl-9 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>Role</Label>
|
||||
<Select
|
||||
value={inviteForm.scope}
|
||||
onValueChange={(value) => setInviteForm({...inviteForm, scope: value as MemberRole})}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-none" style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="Member">Member</SelectItem>
|
||||
<SelectItem value="Admin">Admin</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!inviteForm.email.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <UserPlus className="w-4 h-4 mr-2" />}
|
||||
Send Invitation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ 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" };
|
||||
|
||||
@ -14,8 +15,8 @@ export function MembersSettings() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const { data: projectInfo } = useProjectInfo(projectName);
|
||||
const queryClient = useQueryClient();
|
||||
const isCurrentAdmin = projectInfo?.role === "Owner" || projectInfo?.role === "Admin";
|
||||
const isCurrentOwner = projectInfo?.role === "Owner";
|
||||
const isCurrentAdmin = isProjectAdminRole(projectInfo?.role);
|
||||
const isCurrentOwner = isProjectOwnerRole(projectInfo?.role);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["project-members-grouped", projectName],
|
||||
@ -64,7 +65,7 @@ export function MembersSettings() {
|
||||
<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 && member.scope !== "Owner" && !isSelf;
|
||||
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)" }}>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Outlet, useParams, NavLink } from "react-router-dom";
|
||||
import { useProjectInfo } from "@/hooks/useProjectInfo";
|
||||
import { Loader2, Lock, Settings, Users, Key, Tag, CreditCard } from "lucide-react";
|
||||
import { isProjectAdminRole } from "@/lib/project-permissions";
|
||||
|
||||
export function ProjectSettingsLayout() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const { data: info, isLoading, error } = useProjectInfo(projectName);
|
||||
|
||||
const isAdmin = info?.role === "Owner" || info?.role === "Admin";
|
||||
const isAdmin = isProjectAdminRole(info?.role);
|
||||
|
||||
const tabs = [
|
||||
{ to: "", end: true, icon: Settings, label: "General" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user