Skip to content
Merged
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
31 changes: 31 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,11 @@ app.on("open-url", (event, url) => {
return;
}

if (url.includes("/invitations/")) {
handleInvitationDeepLink(url);
return;
}

void handleOAuthDeepLink(url);

if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) {
Expand All @@ -471,6 +476,30 @@ app.on("open-url", (event, url) => {
}
});

function handleInvitationDeepLink(deepLinkUrl) {
try {
const match = deepLinkUrl.match(/invitations\/([^/?#]+)/);
const token = match?.[1];
if (!token) return;
if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
windowManager.controlPanelWindow.webContents.send("workspace-invitation-token", token);
} else if (windowManager) {
windowManager.createControlPanelWindow();
// Defer the send until renderer is ready; main.js relies on `did-finish-load`
const win = windowManager.controlPanelWindow;
if (win) {
win.webContents.once("did-finish-load", () => {
win.webContents.send("workspace-invitation-token", token);
});
}
}
} catch (error) {
console.error("Invitation deep link parse failed:", error);
}
}

function resolveAuthUrl() {
const fs = require("fs");
const envPath = path.join(__dirname, "src", "dist", "runtime-env.json");
Expand Down Expand Up @@ -1452,6 +1481,8 @@ if (gotSingleInstanceLock) {
if (url) {
if (url.includes("upgrade-success")) {
handleUpgradeDeepLink();
} else if (url.includes("/invitations/")) {
handleInvitationDeepLink(url);
} else {
void handleOAuthDeepLink(url);
}
Expand Down
6 changes: 6 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
notifyLimitReached: (data) => ipcRenderer.send("limit-reached", data),
onLimitReached: registerListener("limit-reached", (callback) => (_event, data) => callback(data)),

// Workspace invitation deep link
onWorkspaceInvitationToken: registerListener(
"workspace-invitation-token",
(callback) => (_event, token) => callback(token)
),

// Globe key listener for hotkey capture (macOS only)
onGlobeKeyPressed: (callback) => {
const listener = () => callback?.();
Expand Down
128 changes: 128 additions & 0 deletions src/components/AcceptInvitationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { InvitationsService } from "../services/InvitationsService";
import { useWorkspaceStore } from "../stores/workspaceStore";
import { useToast } from "./ui/useToast";
import type { InvitationPreview } from "../types/electron";

interface Props {
token: string | null;
onClose: () => void;
isSignedIn: boolean;
onSignIn: () => void;
}

const STORAGE_KEY = "pendingInvitationToken";

export default function AcceptInvitationModal({ token, onClose, isSignedIn, onSignIn }: Props) {
const { t } = useTranslation();
const { toast } = useToast();
const refresh = useWorkspaceStore((s) => s.refresh);
const setActive = useWorkspaceStore((s) => s.setActiveWorkspaceId);
const [preview, setPreview] = useState<InvitationPreview | null>(null);
const [loading, setLoading] = useState(false);
const [accepting, setAccepting] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!token) {
setPreview(null);
setError(null);
return;
}
setLoading(true);
InvitationsService.preview(token)
.then(setPreview)
.catch((err) => setError(err instanceof Error ? err.message : t("common.unknownError")))
.finally(() => setLoading(false));
}, [token, t]);

async function handleAccept() {
if (!token) return;
if (!isSignedIn) {
localStorage.setItem(STORAGE_KEY, token);
onSignIn();
return;
}
setAccepting(true);
try {
const result = await InvitationsService.accept(token);
localStorage.removeItem(STORAGE_KEY);
await refresh();
setActive(result.workspace_id);
toast({
title: t("workspaces.accept.successTitle"),
description: preview ? t("workspaces.accept.successDescription", { name: preview.workspace_name }) : undefined,
});
onClose();
} catch (err) {
toast({
title: t("workspaces.accept.errorTitle"),
description: err instanceof Error ? err.message : t("common.unknownError"),
variant: "destructive",
});
} finally {
setAccepting(false);
}
}

function handleDecline() {
if (token) localStorage.removeItem(STORAGE_KEY);
onClose();
}

return (
<Dialog open={!!token} onOpenChange={(open) => !open && handleDecline()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("workspaces.accept.title")}</DialogTitle>
{preview && (
<DialogDescription>
{t("workspaces.accept.description", {
inviter: preview.inviter_name || preview.inviter_email || "",
workspace: preview.workspace_name,
role: preview.workspace_role,
})}
</DialogDescription>
)}
{error && <DialogDescription className="text-destructive">{error}</DialogDescription>}
{loading && (
<DialogDescription>{t("workspaces.accept.loading")}</DialogDescription>
)}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={handleDecline} disabled={accepting}>
{t("common.cancel")}
</Button>
<Button onClick={handleAccept} disabled={!preview || accepting || !!error}>
{accepting
? t("workspaces.accept.accepting")
: isSignedIn
? t("workspaces.accept.accept")
: t("workspaces.accept.signInToAccept")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

export function consumePendingInvitationToken(): string | null {
if (typeof window === "undefined") return null;
const token = localStorage.getItem(STORAGE_KEY);
return token;
}

export function clearPendingInvitationToken(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEY);
}
30 changes: 30 additions & 0 deletions src/components/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import { fetchProviders as fetchStreamingProviders } from "../stores/streamingPr
import HistoryView from "./HistoryView";
import BackgroundActionToastListener from "./notes/BackgroundActionToastListener";
import { syncService } from "../services/SyncService.js";
import AcceptInvitationModal, {
consumePendingInvitationToken,
clearPendingInvitationToken,
} from "./AcceptInvitationModal";

const platform = getCachedPlatform();

Expand All @@ -68,6 +72,7 @@ export default function ControlPanel() {
() => localStorage.getItem("aiCTADismissed") === "true"
);
const [showReferrals, setShowReferrals] = useState(false);
const [invitationToken, setInvitationToken] = useState<string | null>(null);
const [showSearch, setShowSearch] = useState(false);
const [showCloudMigrationBanner, setShowCloudMigrationBanner] = useState(false);
const [activeView, setActiveView] = useState<ControlPanelView>("home");
Expand Down Expand Up @@ -238,6 +243,22 @@ export default function ControlPanel() {
});
}, [usage?.isPastDue, usage?.hasLoaded, toast, t]);

useEffect(() => {
const unsubscribe = window.electronAPI?.onWorkspaceInvitationToken?.((token) => {
setInvitationToken(token);
});
return () => unsubscribe?.();
}, []);

useEffect(() => {
if (!authLoaded || !isSignedIn) return;
const pending = consumePendingInvitationToken();
if (pending) {
setInvitationToken(pending);
clearPendingInvitationToken();
}
}, [authLoaded, isSignedIn]);

useEffect(() => {
if (!authLoaded || !isSignedIn || cloudMigrationProcessed.current) return;
const isPending = localStorage.getItem("pendingCloudMigration") === "true";
Expand Down Expand Up @@ -653,6 +674,15 @@ export default function ControlPanel() {
</Suspense>
)}

<AcceptInvitationModal
token={invitationToken}
onClose={() => setInvitationToken(null)}
isSignedIn={isSignedIn}
onSignIn={() => {
setInvitationToken(null);
}}
/>

{showSearch && (
<Suspense fallback={null}>
<CommandSearch
Expand Down
44 changes: 44 additions & 0 deletions src/components/ControlPanelSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Settings,
HelpCircle,
UserCircle,
UserPlus,
X,
Search,
} from "lucide-react";
Expand All @@ -18,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { cn } from "./lib/utils";
import SupportDropdown from "./ui/SupportDropdown";
import { getCachedPlatform } from "../utils/platform";
import WorkspaceSwitcher from "./WorkspaceSwitcher";
import InviteTeammateDialog from "./InviteTeammateDialog";
import CreateWorkspaceDialog from "./CreateWorkspaceDialog";
import { useWorkspace } from "../hooks/useWorkspace";

const platform = getCachedPlatform();

Expand Down Expand Up @@ -68,6 +73,9 @@ export default function ControlPanelSidebar({
const [upgradeDismissed, setUpgradeDismissed] = useState(
() => localStorage.getItem("upgradeProDismissed") === "true"
);
const [inviteOpen, setInviteOpen] = useState(false);
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false);
const { active: activeWorkspace } = useWorkspace();

const showLimitBanner = authLoaded && isSignedIn && !isProUser && isOverLimit;
const showUpgradeBanner =
Expand Down Expand Up @@ -97,6 +105,12 @@ export default function ControlPanelSidebar({
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>

{isSignedIn && (
<div className="px-2 pt-1 pb-1">
<WorkspaceSwitcher userName={userName} />
</div>
)}

{onOpenSearch && (
<div className="px-2 pt-2 pb-1">
<button
Expand Down Expand Up @@ -239,6 +253,26 @@ export default function ControlPanelSidebar({
</button>
)}

{isSignedIn && (
<button
onClick={() =>
activeWorkspace ? setInviteOpen(true) : setCreateWorkspaceOpen(true)
}
aria-label={
activeWorkspace ? t("sidebar.inviteTeammate") : t("sidebar.createWorkspace")
}
className="group flex items-center gap-2.5 w-full h-8 px-2.5 rounded-md text-left outline-none hover:bg-foreground/4 dark:hover:bg-white/4 focus-visible:ring-1 focus-visible:ring-primary/30 transition-colors duration-150"
>
<UserPlus
size={15}
className="shrink-0 text-foreground/60 group-hover:text-foreground/75 dark:text-foreground/50 dark:group-hover:text-foreground/65 transition-colors duration-150"
/>
<span className="text-xs text-foreground/80 group-hover:text-foreground dark:text-foreground/70 dark:group-hover:text-foreground/85 transition-colors duration-150">
{activeWorkspace ? t("sidebar.inviteTeammate") : t("sidebar.createWorkspace")}
</span>
</button>
)}

<button
onClick={onOpenSettings}
aria-label={t("sidebar.settings")}
Expand Down Expand Up @@ -298,6 +332,16 @@ export default function ControlPanelSidebar({
</div>
</div>
</div>

{activeWorkspace && (
<InviteTeammateDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
workspaceId={activeWorkspace.id}
workspaceName={activeWorkspace.name}
/>
)}
<CreateWorkspaceDialog open={createWorkspaceOpen} onOpenChange={setCreateWorkspaceOpen} />
</div>
);
}
Loading
Loading