feat(frontend): invitations page with project and workspace support

- Add /invitations route with dedicated page and sidebar button
- Display both project invitations (accept/decline) and workspace invitations (accept only)
- Merge and sort both invitation types by most recent first
- Export new SDK functions: workspaceMyInvitations, workspaceAcceptInvitationBySlug
This commit is contained in:
ZhenYi 2026-04-18 19:05:14 +08:00
parent 9b9c12ffc8
commit 0cccec33b2
8 changed files with 539 additions and 6 deletions

View File

@ -57,6 +57,8 @@ import {SettingsPreferences} from '@/app/settings/preferences';
import {SettingsActivity} from '@/app/settings/activity'; import {SettingsActivity} from '@/app/settings/activity';
import NotifyLayout from '@/app/notify/layout'; import NotifyLayout from '@/app/notify/layout';
import NotifyPage from '@/app/notify/page'; import NotifyPage from '@/app/notify/page';
import InvitationsLayout from '@/app/invitations/layout';
import InvitationsPage from '@/app/invitations/page';
import LandingPage from '@/app/page'; import LandingPage from '@/app/page';
import SearchPage from '@/app/search/page'; import SearchPage from '@/app/search/page';
import PricingPage from '@/app/pricing/page'; import PricingPage from '@/app/pricing/page';
@ -143,6 +145,10 @@ function App() {
<Route index element={<NotifyPage/>}/> <Route index element={<NotifyPage/>}/>
</Route> </Route>
<Route path="/invitations" element={<InvitationsLayout/>}>
<Route index element={<InvitationsPage/>}/>
</Route>
<Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}> <Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}>
<Route index element={<RepoOverview/>}/> <Route index element={<RepoOverview/>}/>
<Route path="branches" element={<RepoBranches/>}/> <Route path="branches" element={<RepoBranches/>}/>

View File

@ -0,0 +1,94 @@
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
import { Outlet, useNavigate } from "react-router-dom";
import { SidebarSystem } from "@/components/layout/sidebar-system";
import { SidebarUser } from "@/components/layout/sidebar-user";
import { useSidebarCollapse } from "@/hooks/use-sidebar-collapse";
import { cn } from "@/lib/utils";
import { useLocation } from "react-router-dom";
function InvitationsSidebar() {
const navigate = useNavigate();
const location = useLocation();
const { collapsed, setCollapsed } = useSidebarCollapse();
return (
<aside
className={cn(
"hidden md:flex h-full flex-col border-r bg-background transition-all duration-200",
collapsed ? "w-16" : "w-64",
)}
>
<div
className={cn("border-b h-12 flex items-center", collapsed ? "justify-center" : "justify-between")}
>
{!collapsed && (
<div className="flex min-w-0 items-center gap-3 overflow-hidden px-3">
<div className="bg-muted rounded-full w-8 h-8 flex items-center justify-center">
<UserPlus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
My Invitations
</div>
</div>
)}
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
onClick={() => setCollapsed(!collapsed)}
>
<ChevronLeft className="h-4 w-4" />
</button>
</div>
<nav className="flex-1 p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => navigate("/invitations")}
className={cn(
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
location.pathname === "/invitations" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<UserPlus className="h-4 w-4" />
</span>
{!collapsed && "Invitations"}
</button>
<button
type="button"
onClick={() => navigate("/notify")}
className={cn(
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
location.pathname === "/notify" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<Bell className="h-4 w-4" />
</span>
{!collapsed && "Notifications"}
</button>
</div>
</nav>
<div className="border-t flex flex-col justify-end">
<SidebarSystem collapsed={collapsed} />
<SidebarUser collapsed={collapsed} />
</div>
</aside>
);
}
export default function InvitationsLayout() {
return (
<div className="flex h-screen w-full bg-background">
<InvitationsSidebar />
<main className="flex-1 h-screen overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,316 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Building2,
Check,
FolderKanban,
Loader2,
UserPlus,
X,
} from "lucide-react";
import {
projectMyInvitations,
projectAcceptInvitation,
projectRejectInvitation,
workspaceMyInvitations,
workspaceAcceptInvitationBySlug,
} from "@/client";
import type { InvitationResponse, MyWorkspaceInvitation } from "@/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { getApiErrorMessage } from "@/lib/api-error";
import { useNavigate } from "react-router-dom";
function formatTime(dateStr: string): string {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
}
type UnifiedInvitation =
| ({ type: "project" } & InvitationResponse)
| ({ type: "workspace" } & MyWorkspaceInvitation);
function ProjectInvitationItem({
inv,
onAccept,
onReject,
isAccepting,
isRejecting,
}: {
inv: InvitationResponse;
onAccept: () => void;
onReject: () => void;
isAccepting: boolean;
isRejecting: boolean;
}) {
return (
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-purple-500/10 text-purple-600 border-purple-500/20 flex items-center justify-center">
<FolderKanban className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">Project Invitation</p>
<Badge variant="outline" className="text-xs bg-purple-500/10 text-purple-600 border-purple-500/20">
{inv.scope}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
You&apos;ve been invited to join{" "}
<span className="font-medium text-foreground">{inv.project_name}</span>
</p>
<div className="flex items-center gap-2 mt-1.5">
{inv.invited_by_username && (
<span className="text-xs text-muted-foreground">
by {inv.invited_by_username}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(inv.created_at)}
</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onReject}
disabled={isAccepting || isRejecting}
>
{isRejecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Decline
</Button>
<Button
size="sm"
variant="default"
className="h-8 text-xs gap-1"
onClick={onAccept}
disabled={isAccepting || isRejecting}
>
{isAccepting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Accept
</Button>
</div>
</div>
</div>
</div>
);
}
function WorkspaceInvitationItem({
inv,
onAccept,
isAccepting,
}: {
inv: MyWorkspaceInvitation;
onAccept: () => void;
isAccepting: boolean;
}) {
return (
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-blue-500/10 text-blue-600 border-blue-500/20 flex items-center justify-center">
<Building2 className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">Workspace Invitation</p>
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-600 border-blue-500/20">
{inv.role}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
You&apos;ve been invited to join workspace{" "}
<span className="font-medium text-foreground">{inv.workspace_name}</span>
</p>
<div className="flex items-center gap-2 mt-1.5">
{inv.invited_by_username && (
<span className="text-xs text-muted-foreground">
by {inv.invited_by_username}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(inv.invited_at)}
</span>
</div>
</div>
<div className="flex-shrink-0">
<Button
size="sm"
variant="default"
className="h-8 text-xs gap-1"
onClick={onAccept}
disabled={isAccepting}
>
{isAccepting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Accept
</Button>
</div>
</div>
</div>
</div>
);
}
export default function InvitationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: projectData, isLoading: projectLoading } = useQuery({
queryKey: ["projectInvitations"],
queryFn: async () => {
const resp = await projectMyInvitations();
return resp.data?.data ?? null;
},
});
const { data: workspaceData, isLoading: workspaceLoading } = useQuery({
queryKey: ["workspaceInvitations"],
queryFn: async () => {
const resp = await workspaceMyInvitations();
return resp.data?.data ?? [];
},
});
const acceptProjectMutation = useMutation({
mutationFn: async (projectName: string) => {
await projectAcceptInvitation({ path: { project_name: projectName } });
},
onSuccess: (_data, projectName) => {
toast.success(`You've joined project: ${projectName}`);
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
navigate(`/project/${projectName}`);
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
},
});
const rejectProjectMutation = useMutation({
mutationFn: async (projectName: string) => {
await projectRejectInvitation({ path: { project_name: projectName } });
},
onSuccess: (_data, projectName) => {
toast.success(`Invitation to ${projectName} declined`);
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to decline invitation"));
},
});
const acceptWorkspaceMutation = useMutation({
mutationFn: async (slug: string) => {
await workspaceAcceptInvitationBySlug({
body: { slug },
});
},
onSuccess: () => {
toast.success("You've joined the workspace");
queryClient.invalidateQueries({ queryKey: ["workspaceInvitations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
},
});
const projectInvitations: InvitationResponse[] = projectData?.invitations ?? [];
const workspaceInvitations: MyWorkspaceInvitation[] = workspaceData ?? [];
const total = projectInvitations.length + workspaceInvitations.length;
const unified: UnifiedInvitation[] = [
...projectInvitations.map((inv) => ({ type: "project" as const, ...inv })),
...workspaceInvitations.map((inv) => ({ type: "workspace" as const, ...inv })),
].sort((a, b) => {
const aTime = a.type === "project" ? a.created_at : a.invited_at;
const bTime = b.type === "project" ? b.created_at : b.invited_at;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
const isLoading = projectLoading || workspaceLoading;
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<UserPlus className="h-5 w-5" />
My Invitations
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{total > 0
? `${total} pending invitation${total !== 1 ? "s" : ""}`
: "No pending invitations"}
</p>
</div>
</div>
<div className="border rounded-lg bg-card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : unified.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<UserPlus className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium">No pending invitations</p>
<p className="text-sm mt-1">
Project and workspace invitations will appear here.
</p>
</div>
) : (
unified.map((inv) => {
if (inv.type === "project") {
return (
<ProjectInvitationItem
key={`project-${inv.project_uid}`}
inv={inv}
onAccept={() => acceptProjectMutation.mutate(inv.project_name)}
onReject={() => rejectProjectMutation.mutate(inv.project_name)}
isAccepting={acceptProjectMutation.isPending}
isRejecting={rejectProjectMutation.isPending}
/>
);
} else {
return (
<WorkspaceInvitationItem
key={`workspace-${inv.workspace_id}`}
inv={inv}
onAccept={() => acceptWorkspaceMutation.mutate(inv.workspace_slug)}
isAccepting={acceptWorkspaceMutation.isPending}
/>
);
}
})
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Bell, ChevronLeft } from "lucide-react"; import { Bell, ChevronLeft, UserPlus } from "lucide-react";
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { SidebarSystem } from "@/components/layout/sidebar-system"; import { SidebarSystem } from "@/components/layout/sidebar-system";
import { SidebarUser } from "@/components/layout/sidebar-user"; import { SidebarUser } from "@/components/layout/sidebar-user";
@ -43,6 +43,20 @@ function NotifySidebar() {
<nav className="flex-1 p-2"> <nav className="flex-1 p-2">
<div className="space-y-1"> <div className="space-y-1">
<button
type="button"
onClick={() => navigate("/invitations")}
className={cn(
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
location.pathname === "/invitations" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<UserPlus className="h-4 w-4" />
</span>
{!collapsed && "Invitations"}
</button>
<button <button
type="button" type="button"
onClick={() => navigate("/notify")} onClick={() => navigate("/notify")}
@ -55,7 +69,7 @@ function NotifySidebar() {
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}> <span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
</span> </span>
{!collapsed && "All Notifications"} {!collapsed && "Notifications"}
</button> </button>
</div> </div>
</nav> </nav>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3310,8 +3310,10 @@ export type InvitationListResponse = {
export type InvitationResponse = { export type InvitationResponse = {
project_uid: string; project_uid: string;
project_name: string;
user_uid: string; user_uid: string;
invited_by: string; invited_by: string;
invited_by_username?: string | null;
scope: string; scope: string;
accepted: boolean; accepted: boolean;
accepted_at?: string | null; accepted_at?: string | null;
@ -3814,6 +3816,16 @@ export type PendingInvitationInfo = {
expires_at?: string | null; expires_at?: string | null;
}; };
export type MyWorkspaceInvitation = {
workspace_id: string;
workspace_slug: string;
workspace_name: string;
role: string;
invited_by_username?: string | null;
invited_at: string;
expires_at?: string | null;
};
export type PrCommitResponse = { export type PrCommitResponse = {
oid: string; oid: string;
short_oid: string; short_oid: string;
@ -5127,6 +5139,10 @@ export type WorkspaceInviteParams = {
role?: string | null; role?: string | null;
}; };
export type WorkspaceAcceptBySlugParams = {
slug: string;
};
export type WorkspaceListItem = { export type WorkspaceListItem = {
id: string; id: string;
slug: string; slug: string;
@ -18237,6 +18253,66 @@ export type WorkspaceAcceptInvitationResponses = {
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses]; export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
export type WorkspaceMyInvitationsData = {
body?: never;
path?: never;
query?: never;
url: '/api/workspaces/me/invitations';
};
export type WorkspaceMyInvitationsErrors = {
/**
* Unauthorized
*/
401: unknown;
};
export type ApiResponseVecMyWorkspaceInvitation = {
code: number;
message: string;
data?: Array<MyWorkspaceInvitation>;
};
export type WorkspaceMyInvitationsResponses = {
/**
* List my workspace invitations
*/
200: ApiResponseVecMyWorkspaceInvitation;
};
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
export type WorkspaceAcceptInvitationBySlugData = {
body: WorkspaceAcceptBySlugParams;
path?: never;
query?: never;
url: '/api/workspaces/invitations/accept-by-slug';
};
export type WorkspaceAcceptInvitationBySlugErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Invitation not found
*/
404: unknown;
/**
* Already accepted
*/
409: unknown;
};
export type WorkspaceAcceptInvitationBySlugResponses = {
/**
* Accept invitation
*/
200: ApiResponseWorkspaceInfoResponse;
};
export type WorkspaceAcceptInvitationBySlugResponse = WorkspaceAcceptInvitationBySlugResponses[keyof WorkspaceAcceptInvitationBySlugResponses];
export type WorkspaceListData = { export type WorkspaceListData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@ -10,7 +10,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {useUser} from '@/contexts'; import {useUser} from '@/contexts';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {Mail} from 'lucide-react'; import {Mail, UserPlus} from 'lucide-react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm'; const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
@ -21,6 +21,14 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
return ( return (
<div className="w-full mb-2"> <div className="w-full mb-2">
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/invitations')}>
<span className="flex h-6 items-center shrink-0 w-6">
<UserPlus className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Invitations</span>}
</button>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} <button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/notify')}> onClick={() => navigate('/notify')}>
<span className="relative flex h-6 items-center shrink-0 w-6"> <span className="relative flex h-6 items-center shrink-0 w-6">