mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902e156586 | ||
|
|
dfb9169aaf |
@@ -18,6 +18,7 @@ import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
@@ -57,6 +58,13 @@ function OnboardingRoute() {
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -97,6 +105,11 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <OnboardingRoute />,
|
||||
handle: { title: "Get Started" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
24
apps/web/app/(auth)/invite/[id]/page.tsx
Normal file
24
apps/web/app/(auth)/invite/[id]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(`/login?next=/invite/${params.id}`);
|
||||
}
|
||||
}, [isLoading, user, router, params.id]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitePage invitationId={params.id} />;
|
||||
}
|
||||
@@ -37,6 +37,15 @@ function LoginPageContent() {
|
||||
router.push(ws ? nextUrl : "/onboarding");
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={handleSuccess}
|
||||
@@ -45,7 +54,7 @@ function LoginPageContent() {
|
||||
? {
|
||||
clientId: googleClientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
state: platform === "desktop" ? "platform:desktop" : undefined,
|
||||
state: googleState,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -39,8 +39,11 @@ function CallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = searchParams.get("state");
|
||||
const isDesktop = state === "platform:desktop";
|
||||
const state = searchParams.get("state") || "";
|
||||
const stateParts = state.split(",");
|
||||
const isDesktop = stateParts.includes("platform:desktop");
|
||||
const nextPart = stateParts.find((p) => p.startsWith("next:"));
|
||||
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
@@ -63,7 +66,9 @@ function CallbackContent() {
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
const ws = await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(ws ? "/issues" : "/onboarding");
|
||||
// Honor the ?next= redirect if present (e.g. /invite/{id})
|
||||
const defaultDest = ws ? "/issues" : "/onboarding";
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -593,6 +593,10 @@ export class ApiClient {
|
||||
return this.fetch("/api/invitations");
|
||||
}
|
||||
|
||||
async getInvitation(invitationId: string): Promise<Invitation> {
|
||||
return this.fetch(`/api/invitations/${invitationId}`);
|
||||
}
|
||||
|
||||
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
|
||||
return this.fetch(`/api/invitations/${invitationId}/accept`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createPersistStorage } from "../platform/persist-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
|
||||
|
||||
interface NavigationState {
|
||||
lastPath: string;
|
||||
|
||||
2
packages/views/invite/index.ts
Normal file
2
packages/views/invite/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InvitePage } from "./invite-page";
|
||||
export type { InvitePageProps } from "./invite-page";
|
||||
185
packages/views/invite/invite-page.tsx
Normal file
185
packages/views/invite/invite-page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Users, Check, X } from "lucide-react";
|
||||
|
||||
export interface InvitePageProps {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
const { push } = useNavigation();
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
const qc = useQueryClient();
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [declining, setDeclining] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<"accepted" | "declined" | null>(null);
|
||||
|
||||
const { data: invitation, isLoading, error: fetchError } = useQuery({
|
||||
queryKey: ["invitation", invitationId],
|
||||
queryFn: () => api.getInvitation(invitationId),
|
||||
});
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.acceptInvitation(invitationId);
|
||||
setDone("accepted");
|
||||
// Refresh workspace list and switch to the new workspace.
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
const ws = wsList.find((w) => w.id === invitation?.workspace_id);
|
||||
if (ws) {
|
||||
switchWorkspace(ws);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
// Navigate to the workspace after a short delay for the success state.
|
||||
setTimeout(() => push("/issues"), 1000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to accept invitation");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
setDeclining(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.declineInvitation(invitationId);
|
||||
setDone("declined");
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to decline invitation");
|
||||
} finally {
|
||||
setDeclining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">Loading invitation...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<X className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Invitation not found</h2>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
This invitation may have expired, been revoked, or doesn't belong to your account.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => push("/issues")}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "accepted") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Check className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">You joined {invitation.workspace_name}!</h2>
|
||||
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "declined") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<h2 className="text-lg font-semibold">Invitation declined</h2>
|
||||
<p className="text-sm text-muted-foreground">You won't be added to this workspace.</p>
|
||||
<Button variant="outline" onClick={() => push("/issues")}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpired = invitation.status !== "pending";
|
||||
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-6 py-12">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Join {invitation.workspace_name ?? "workspace"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>{invitation.inviter_name || invitation.inviter_email}</strong>{" "}
|
||||
invited you to join as {invitation.role === "admin" ? "an admin" : "a member"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAlreadyHandled ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This invitation has already been {invitation.status}.
|
||||
</div>
|
||||
) : isExpired ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This invitation has expired.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDecline}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{declining ? "Declining..." : "Decline"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{accepting ? "Joining..." : "Accept & Join"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
"./chat": "./chat/index.ts",
|
||||
"./settings": "./settings/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./platform": "./platform/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -187,6 +187,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
|
||||
// User-scoped invitation routes (no workspace context required)
|
||||
r.Get("/api/invitations", h.ListMyInvitations)
|
||||
r.Get("/api/invitations/{id}", h.GetMyInvitation)
|
||||
r.Post("/api/invitations/{id}/accept", h.AcceptInvitation)
|
||||
r.Post("/api/invitations/{id}/decline", h.DeclineInvitation)
|
||||
|
||||
|
||||
@@ -147,8 +147,9 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
if inviter, err := h.Queries.GetUser(r.Context(), requester.UserID); err == nil {
|
||||
inviterName = inviter.Name
|
||||
}
|
||||
invID := uuidToString(inv.ID)
|
||||
go func() {
|
||||
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName); err != nil {
|
||||
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName, invID); err != nil {
|
||||
slog.Warn("failed to send invitation email", "email", email, "error", err)
|
||||
}
|
||||
}()
|
||||
@@ -224,6 +225,49 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetMyInvitation — get a single invitation by ID (for the invite accept page).
|
||||
// GET /api/invitations/{id}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
invitationID := chi.URLParam(r, "id")
|
||||
inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "invitation not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the invitation belongs to the current user.
|
||||
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
|
||||
writeError(w, http.StatusForbidden, "invitation does not belong to you")
|
||||
return
|
||||
}
|
||||
|
||||
resp := invitationToResponse(inv)
|
||||
|
||||
// Enrich with workspace name and inviter name.
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), inv.WorkspaceID); err == nil {
|
||||
resp.WorkspaceName = ws.Name
|
||||
}
|
||||
if inviter, err := h.Queries.GetUser(r.Context(), inv.InviterID); err == nil {
|
||||
resp.InviterName = inviter.Name
|
||||
resp.InviterEmail = inviter.Email
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMyInvitations — current user's pending invitations across all workspaces.
|
||||
// GET /api/invitations
|
||||
|
||||
@@ -55,16 +55,16 @@ func (s *EmailService) SendVerificationCode(to, code string) error {
|
||||
}
|
||||
|
||||
// SendInvitationEmail notifies the invitee that they have been invited to a workspace.
|
||||
func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName string) error {
|
||||
// Build the app URL for the invitation — users will see pending invitations
|
||||
// in the workspace switcher after logging in.
|
||||
// invitationID is included in the URL so the email deep-links to /invite/{id}.
|
||||
func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName, invitationID string) error {
|
||||
appURL := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
|
||||
if appURL == "" {
|
||||
appURL = "https://app.multica.ai"
|
||||
}
|
||||
inviteURL := fmt.Sprintf("%s/invite/%s", appURL, invitationID)
|
||||
|
||||
if s.client == nil {
|
||||
fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, appURL)
|
||||
fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, inviteURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,10 +77,10 @@ func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName string
|
||||
<h2>You're invited to join %s</h2>
|
||||
<p><strong>%s</strong> invited you to collaborate in the <strong>%s</strong> workspace on Multica.</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Open Multica</a>
|
||||
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Accept invitation</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">Log in to accept or decline the invitation.</p>
|
||||
</div>`, workspaceName, inviterName, workspaceName, appURL),
|
||||
<p style="color: #666; font-size: 14px;">You'll need to log in to accept or decline the invitation.</p>
|
||||
</div>`, workspaceName, inviterName, workspaceName, inviteURL),
|
||||
}
|
||||
|
||||
_, err := s.client.Emails.Send(params)
|
||||
|
||||
Reference in New Issue
Block a user