Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ import {
EMPTY_LABEL,
EMPTY_LIST_LABELS_RESPONSE,
EMPTY_RESOURCE_LABELS_RESPONSE,
InvitationSchema,
InvitationListSchema,
EMPTY_INVITATION,
EMPTY_INVITATION_LIST,
} from "./schemas";

/** Identifies the calling client to the server.
Expand Down Expand Up @@ -1621,10 +1625,13 @@ export class ApiClient {
}

async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, InvitationSchema, EMPTY_INVITATION, {
endpoint: `POST /api/workspaces/${workspaceId}/members`,
});
}

async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
Expand All @@ -1648,7 +1655,10 @@ export class ApiClient {

// Invitations
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/invitations`);
return parseWithFallback(raw, InvitationListSchema, EMPTY_INVITATION_LIST, {
endpoint: `GET /api/workspaces/${workspaceId}/invitations`,
});
}

async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
Expand All @@ -1658,11 +1668,17 @@ export class ApiClient {
}

async listMyInvitations(): Promise<Invitation[]> {
return this.fetch("/api/invitations");
const raw = await this.fetch<unknown>("/api/invitations");
return parseWithFallback(raw, InvitationListSchema, EMPTY_INVITATION_LIST, {
endpoint: "GET /api/invitations",
});
}

async getInvitation(invitationId: string): Promise<Invitation> {
return this.fetch(`/api/invitations/${invitationId}`);
const raw = await this.fetch<unknown>(`/api/invitations/${invitationId}`);
return parseWithFallback(raw, InvitationSchema, EMPTY_INVITATION, {
endpoint: `GET /api/invitations/${invitationId}`,
});
}

async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
Expand Down
31 changes: 31 additions & 0 deletions packages/core/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
EMPTY_SEARCH_PROJECTS_RESPONSE,
EMPTY_USER,
InboxUnreadSummarySchema,
InvitationSchema,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
SearchProjectsResponseSchema,
Expand Down Expand Up @@ -191,6 +192,36 @@ describe("TimelineEntriesSchema", () => {
});
});

describe("InvitationSchema", () => {
const invitation = {
id: "22222222-2222-2222-2222-222222222222",
workspace_id: "ws-1",
inviter_id: "user-1",
invitee_email: "invitee@example.com",
invitee_user_id: null,
role: "member",
status: "pending",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
expires_at: "2026-01-08T00:00:00Z",
};

it("preserves invite_url from newer backends", () => {
const parsed = InvitationSchema.parse({
...invitation,
invite_url: "https://app.example.com/invite/22222222-2222-2222-2222-222222222222",
});

expect(parsed.invite_url).toBe("https://app.example.com/invite/22222222-2222-2222-2222-222222222222");
});

it("derives a relative invite_url when older backends omit it", () => {
const parsed = InvitationSchema.parse(invitation);

expect(parsed.invite_url).toBe("/invite/22222222-2222-2222-2222-222222222222");
});
});

describe("AgentTaskListSchema", () => {
const task = {
id: "task-1",
Expand Down
42 changes: 42 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
TimelineEntry,
User,
WebhookDelivery,
Invitation,
} from "../types";
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
import type { CreateFeedbackResponse } from "../feedback/types";
Expand Down Expand Up @@ -1059,6 +1060,47 @@ export const EMPTY_USER: User = {
updated_at: "",
};

// Workspace invitations are rendered in Settings → Members and the invite
// acceptance surfaces. Keep the invite_url default relative so older backends
// still expose a copyable link when the id is present.
export const InvitationSchema = z.object({
id: z.string(),
workspace_id: z.string(),
inviter_id: z.string(),
invitee_email: z.string(),
invitee_user_id: z.string().nullable().default(null),
role: z.string().default("member"),
status: z.string().default("pending"),
created_at: z.string().default(""),
updated_at: z.string().default(""),
expires_at: z.string().default(""),
invite_url: z.string().optional(),
inviter_name: z.string().optional(),
inviter_email: z.string().optional(),
workspace_name: z.string().optional(),
}).loose().transform((inv) => ({
...inv,
invite_url: inv.invite_url || `/invite/${inv.id}`,
}));

export const InvitationListSchema = z.array(InvitationSchema);

export const EMPTY_INVITATION: Invitation = {
id: "",
workspace_id: "",
inviter_id: "",
invitee_email: "",
invitee_user_id: null,
role: "member",
status: "pending",
created_at: "",
updated_at: "",
expires_at: "",
invite_url: "",
};

export const EMPTY_INVITATION_LIST: Invitation[] = [];

// ---------------------------------------------------------------------------
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
// One entry per workspace the user belongs to that has unread items; the
Expand Down
1 change: 1 addition & 0 deletions packages/core/types/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface Invitation {
created_at: string;
updated_at: string;
expires_at: string;
invite_url: string;
inviter_name?: string;
inviter_email?: string;
workspace_name?: string;
Expand Down
5 changes: 5 additions & 0 deletions packages/views/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,11 @@
"invite_button": "Invite",
"inviting": "Inviting...",
"toast_invitation_sent": "Invitation sent",
"toast_invitation_link_ready": "Copy the invite link if email delivery is not configured.",
"copy_invitation_link": "Copy link",
"copy_invitation_link_tooltip": "Copy invitation link",
"toast_invitation_link_copied": "Invitation link copied",
"toast_invitation_link_copy_failed": "Failed to copy invitation link",
"toast_invitation_failed": "Failed to send invitation",
"no_members": "No members found.",
"pending_title": "Pending invitations ({{count}})",
Expand Down
5 changes: 5 additions & 0 deletions packages/views/locales/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,11 @@
"invite_button": "招待",
"inviting": "招待中...",
"toast_invitation_sent": "招待を送信しました",
"toast_invitation_link_ready": "メール配信が未設定の場合は、招待リンクをコピーして共有できます。",
"copy_invitation_link": "リンクをコピー",
"copy_invitation_link_tooltip": "招待リンクをコピー",
"toast_invitation_link_copied": "招待リンクをコピーしました",
"toast_invitation_link_copy_failed": "招待リンクをコピーできませんでした",
"toast_invitation_failed": "招待を送信できませんでした",
"no_members": "メンバーが見つかりません。",
"pending_title": "保留中の招待({{count}})",
Expand Down
5 changes: 5 additions & 0 deletions packages/views/locales/ko/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@
"invite_button": "초대",
"inviting": "초대하는 중...",
"toast_invitation_sent": "초대를 보냈습니다",
"toast_invitation_link_ready": "이메일 전송이 설정되지 않았다면 초대 링크를 복사해 공유하세요.",
"copy_invitation_link": "링크 복사",
"copy_invitation_link_tooltip": "초대 링크 복사",
"toast_invitation_link_copied": "초대 링크를 복사했습니다",
"toast_invitation_link_copy_failed": "초대 링크를 복사하지 못했습니다",
"toast_invitation_failed": "초대를 보내지 못했습니다",
"no_members": "멤버를 찾을 수 없습니다.",
"pending_title": "대기 중인 초대 ({{count}})",
Expand Down
5 changes: 5 additions & 0 deletions packages/views/locales/zh-Hans/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,11 @@
"invite_button": "邀请",
"inviting": "邀请中...",
"toast_invitation_sent": "已发送邀请",
"toast_invitation_link_ready": "如果未配置邮件发送,可复制邀请链接线下发送。",
"copy_invitation_link": "复制链接",
"copy_invitation_link_tooltip": "复制邀请链接",
"toast_invitation_link_copied": "已复制邀请链接",
"toast_invitation_link_copy_failed": "复制邀请链接失败",
"toast_invitation_failed": "发送邀请失败",
"no_members": "未找到成员。",
"pending_title": "待处理邀请({{count}})",
Expand Down
63 changes: 48 additions & 15 deletions packages/views/settings/components/members-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Clock, X, Mail } from "lucide-react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Clock, X, Mail, Copy } from "lucide-react";
import { ActorAvatar } from "../../common/actor-avatar";
import type { MemberWithUser, MemberRole, Invitation } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
Expand Down Expand Up @@ -185,11 +185,13 @@ function MemberRow({
function InvitationRow({
invitation,
canManage,
onCopyLink,
onRevoke,
busy,
}: {
invitation: Invitation;
canManage: boolean;
onCopyLink: () => void;
onRevoke: () => void;
busy: boolean;
}) {
Expand All @@ -204,21 +206,33 @@ function InvitationRow({
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{invitation.invitee_email}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{t(($) => $.members.pending_status)}</span>
<div className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3 shrink-0" />
<span className="shrink-0">{t(($) => $.members.pending_status)}</span>
<span className="truncate font-mono">{invitation.invite_url}</span>
</div>
</div>
{canManage && (
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onRevoke}
title={t(($) => $.members.revoke_invitation_tooltip)}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onCopyLink}
title={t(($) => $.members.copy_invitation_link_tooltip)}
>
<Copy className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onRevoke}
title={t(($) => $.members.revoke_invitation_tooltip)}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
)}
<Badge variant="outline">
{rc.label}
Expand Down Expand Up @@ -254,18 +268,36 @@ export function MembersTab() {
const isOwner = currentMember?.role === "owner";
const ownerCount = members.filter((m) => m.role === "owner").length;

const copyInvitationLink = async (url: string) => {
const link = url.startsWith("/") ? `${window.location.origin}${url}` : url;
try {
await navigator.clipboard.writeText(link);
toast.success(t(($) => $.members.toast_invitation_link_copied));
} catch {
toast.error(t(($) => $.members.toast_invitation_link_copy_failed));
}
};

const handleInviteMember = async () => {
if (!workspace) return;
setInviteLoading(true);
try {
await api.createMember(workspace.id, {
const invitation = await api.createMember(workspace.id, {
email: inviteEmail,
role: inviteRole,
});
setInviteEmail("");
setInviteRole("member");
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(wsId) });
toast.success(t(($) => $.members.toast_invitation_sent));
toast.success(t(($) => $.members.toast_invitation_sent), {
description: t(($) => $.members.toast_invitation_link_ready),
action: {
label: t(($) => $.members.copy_invitation_link),
onClick: () => {
void copyInvitationLink(invitation.invite_url);
},
},
});
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.members.toast_invitation_failed));
} finally {
Expand Down Expand Up @@ -406,6 +438,7 @@ export function MembersTab() {
<InvitationRow
invitation={inv}
canManage={canManageWorkspace}
onCopyLink={() => copyInvitationLink(inv.invite_url)}
onRevoke={() => handleRevokeInvitation(inv)}
busy={invitationActionId === inv.id}
/>
Expand Down
Loading