diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 90592a04fad..ab060f54572 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -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. @@ -1621,10 +1625,13 @@ export class ApiClient { } async createMember(workspaceId: string, data: CreateMemberRequest): Promise { - return this.fetch(`/api/workspaces/${workspaceId}/members`, { + const raw = await this.fetch(`/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 { @@ -1648,7 +1655,10 @@ export class ApiClient { // Invitations async listWorkspaceInvitations(workspaceId: string): Promise { - return this.fetch(`/api/workspaces/${workspaceId}/invitations`); + const raw = await this.fetch(`/api/workspaces/${workspaceId}/invitations`); + return parseWithFallback(raw, InvitationListSchema, EMPTY_INVITATION_LIST, { + endpoint: `GET /api/workspaces/${workspaceId}/invitations`, + }); } async revokeInvitation(workspaceId: string, invitationId: string): Promise { @@ -1658,11 +1668,17 @@ export class ApiClient { } async listMyInvitations(): Promise { - return this.fetch("/api/invitations"); + const raw = await this.fetch("/api/invitations"); + return parseWithFallback(raw, InvitationListSchema, EMPTY_INVITATION_LIST, { + endpoint: "GET /api/invitations", + }); } async getInvitation(invitationId: string): Promise { - return this.fetch(`/api/invitations/${invitationId}`); + const raw = await this.fetch(`/api/invitations/${invitationId}`); + return parseWithFallback(raw, InvitationSchema, EMPTY_INVITATION, { + endpoint: `GET /api/invitations/${invitationId}`, + }); } async acceptInvitation(invitationId: string): Promise { diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index d0cf00cc425..33d69c38e09 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -12,6 +12,7 @@ import { EMPTY_SEARCH_PROJECTS_RESPONSE, EMPTY_USER, InboxUnreadSummarySchema, + InvitationSchema, IssueTriggerPreviewSchema, ListIssuesResponseSchema, SearchProjectsResponseSchema, @@ -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", diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 1872bac1e97..b5354144a94 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -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"; @@ -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 diff --git a/packages/core/types/workspace.ts b/packages/core/types/workspace.ts index dbf3f5591f8..a33bde5b138 100644 --- a/packages/core/types/workspace.ts +++ b/packages/core/types/workspace.ts @@ -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; diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 8f922bb9ae7..49e4c4cc55d 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -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}})", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index c8565ed2107..71f046ab005 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -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}})", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index 76ba30ec425..20325238e8d 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -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}})", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index e59b3866e28..ccd381b1eaa 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -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}})", diff --git a/packages/views/settings/components/members-tab.tsx b/packages/views/settings/components/members-tab.tsx index c7232f0bbf5..082d8dbcb93 100644 --- a/packages/views/settings/components/members-tab.tsx +++ b/packages/views/settings/components/members-tab.tsx @@ -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"; @@ -185,11 +185,13 @@ function MemberRow({ function InvitationRow({ invitation, canManage, + onCopyLink, onRevoke, busy, }: { invitation: Invitation; canManage: boolean; + onCopyLink: () => void; onRevoke: () => void; busy: boolean; }) { @@ -204,21 +206,33 @@ function InvitationRow({
{invitation.invitee_email}
-
- - {t(($) => $.members.pending_status)} +
+ + {t(($) => $.members.pending_status)} + {invitation.invite_url}
{canManage && ( - +
+ + +
)} {rc.label} @@ -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 { @@ -406,6 +438,7 @@ export function MembersTab() { copyInvitationLink(inv.invite_url)} onRevoke={() => handleRevokeInvitation(inv)} busy={invitationActionId === inv.id} /> diff --git a/server/internal/handler/invitation.go b/server/internal/handler/invitation.go index c020ffb72b5..52fe67b1c6e 100644 --- a/server/internal/handler/invitation.go +++ b/server/internal/handler/invitation.go @@ -2,8 +2,10 @@ package handler import ( "encoding/json" + "fmt" "log/slog" "net/http" + "os" "strings" "time" @@ -28,15 +30,17 @@ type InvitationResponse struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` ExpiresAt string `json:"expires_at"` + InviteURL string `json:"invite_url"` // Enriched fields (present in list responses). InviterName string `json:"inviter_name,omitempty"` InviterEmail string `json:"inviter_email,omitempty"` WorkspaceName string `json:"workspace_name,omitempty"` } -func invitationToResponse(inv db.WorkspaceInvitation) InvitationResponse { +func (h *Handler) invitationToResponse(inv db.WorkspaceInvitation) InvitationResponse { + id := uuidToString(inv.ID) return InvitationResponse{ - ID: uuidToString(inv.ID), + ID: id, WorkspaceID: uuidToString(inv.WorkspaceID), InviterID: uuidToString(inv.InviterID), InviteeEmail: inv.InviteeEmail, @@ -46,9 +50,21 @@ func invitationToResponse(inv db.WorkspaceInvitation) InvitationResponse { CreatedAt: timestampToString(inv.CreatedAt), UpdatedAt: timestampToString(inv.UpdatedAt), ExpiresAt: timestampToString(inv.ExpiresAt), + InviteURL: h.invitationURL(id), } } +func (h *Handler) invitationURL(invitationID string) string { + appURL := normalizePublicURL(os.Getenv("MULTICA_APP_URL")) + if appURL == "" { + appURL = normalizePublicURL(os.Getenv("FRONTEND_ORIGIN")) + } + if appURL == "" { + return fmt.Sprintf("/invite/%s", invitationID) + } + return fmt.Sprintf("%s/invite/%s", appURL, invitationID) +} + // --------------------------------------------------------------------------- // CreateInvitation replaces the old "instant-add" CreateMember flow. // POST /api/workspaces/{id}/members (same endpoint, new behaviour) @@ -143,7 +159,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { slog.Info("invitation created", append(logger.RequestAttrs(r), "invitation_id", uuidToString(inv.ID), "workspace_id", workspaceID, "email", email, "role", role)...) - resp := invitationToResponse(inv) + resp := h.invitationToResponse(inv) // Notify the invitee in real time if they are a registered user. userID := requestUserID(r) @@ -199,20 +215,20 @@ func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Reques resp := make([]InvitationResponse, len(rows)) for i, row := range rows { - resp[i] = InvitationResponse{ - ID: uuidToString(row.ID), - WorkspaceID: uuidToString(row.WorkspaceID), - InviterID: uuidToString(row.InviterID), + resp[i] = h.invitationToResponse(db.WorkspaceInvitation{ + ID: row.ID, + WorkspaceID: row.WorkspaceID, + InviterID: row.InviterID, InviteeEmail: row.InviteeEmail, - InviteeUserID: uuidToPtr(row.InviteeUserID), + InviteeUserID: row.InviteeUserID, Role: row.Role, Status: row.Status, - CreatedAt: timestampToString(row.CreatedAt), - UpdatedAt: timestampToString(row.UpdatedAt), - ExpiresAt: timestampToString(row.ExpiresAt), - InviterName: row.InviterName, - InviterEmail: row.InviterEmail, - } + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + ExpiresAt: row.ExpiresAt, + }) + resp[i].InviterName = row.InviterName + resp[i].InviterEmail = row.InviterEmail } writeJSON(w, http.StatusOK, resp) @@ -291,7 +307,7 @@ func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) { return } - resp := invitationToResponse(inv) + resp := h.invitationToResponse(inv) // Enrich with workspace name and inviter name. if ws, err := h.Queries.GetWorkspace(r.Context(), inv.WorkspaceID); err == nil { @@ -333,21 +349,21 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { resp := make([]InvitationResponse, len(rows)) for i, row := range rows { - resp[i] = InvitationResponse{ - ID: uuidToString(row.ID), - WorkspaceID: uuidToString(row.WorkspaceID), - InviterID: uuidToString(row.InviterID), + resp[i] = h.invitationToResponse(db.WorkspaceInvitation{ + ID: row.ID, + WorkspaceID: row.WorkspaceID, + InviterID: row.InviterID, InviteeEmail: row.InviteeEmail, - InviteeUserID: uuidToPtr(row.InviteeUserID), + InviteeUserID: row.InviteeUserID, Role: row.Role, Status: row.Status, - CreatedAt: timestampToString(row.CreatedAt), - UpdatedAt: timestampToString(row.UpdatedAt), - ExpiresAt: timestampToString(row.ExpiresAt), - WorkspaceName: row.WorkspaceName, - InviterName: row.InviterName, - InviterEmail: row.InviterEmail, - } + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + ExpiresAt: row.ExpiresAt, + }) + resp[i].WorkspaceName = row.WorkspaceName + resp[i].InviterName = row.InviterName + resp[i].InviterEmail = row.InviterEmail } writeJSON(w, http.StatusOK, resp)