Skip to content
Closed

wip #2012

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
170 changes: 163 additions & 7 deletions apps/mail/app/(routes)/settings/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,78 @@ import {
DialogTrigger,
DialogClose,
} from '@/components/ui/dialog';
import { AddArcadeConnectionDialog } from '@/components/connection/add-arcade-connection';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useArcadeConnections } from '@/hooks/use-arcade-connections';
import { SettingsCard } from '@/components/settings/settings-card';
import { AddConnectionDialog } from '@/components/connection/add';

import { Trash, Plus, Unplug, Sparkles } from 'lucide-react';
import { useSession, authClient } from '@/lib/auth-client';
import { useConnections } from '@/hooks/use-connections';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { useMutation } from '@tanstack/react-query';
import { Trash, Plus, Unplug } from 'lucide-react';
import { useThreads } from '@/hooks/use-threads';
import { useBilling } from '@/hooks/use-billing';
import { emailProviders } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useState, useEffect } from 'react';
import { m } from '@/paraglide/messages';
import { useQueryState } from 'nuqs';
import { useState } from 'react';
import { toast } from 'sonner';

export default function ConnectionsPage() {
const { data, isLoading, refetch: refetchConnections } = useConnections();
const {
connections: arcadeConnections,
isLoading: arcadeLoading,
refetch: refetchArcadeConnections,
revokeAuthorization,
} = useArcadeConnections();
const { refetch } = useSession();
const [openTooltip, setOpenTooltip] = useState<string | null>(null);

const trpc = useTRPC();
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
const { mutateAsync: createArcadeConnection } = useMutation(
trpc.arcadeConnections.createConnection.mutationOptions(),
);
const [{ refetch: refetchThreads }] = useThreads();
const { isPro } = useBilling();
const [, setPricingDialog] = useQueryState('pricingDialog');
const [arcadeAuthSuccess] = useQueryState('arcade_auth_success');
const [toolkit] = useQueryState('toolkit');
const [authId] = useQueryState('auth_id');
const [error] = useQueryState('error');

useEffect(() => {
if (arcadeAuthSuccess === 'true' && toolkit && authId) {
createArcadeConnection({ toolkit, authId })
.then(() => {
toast.success(`Successfully connected ${toolkit}`);
void refetchArcadeConnections();
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch((err) => {
console.error('Failed to create Arcade connection:', err);
toast.error(`Failed to connect ${toolkit}`);
});
} else if (error) {
let errorMessage = 'Authentication failed';
if (error === 'arcade_auth_failed') {
errorMessage = 'Arcade authorization failed';
} else if (error === 'arcade_auth_incomplete') {
errorMessage = 'Authorization was not completed';
} else if (error === 'arcade_verification_failed') {
errorMessage = 'User verification failed';
} else if (error === 'arcade_auth_error') {
errorMessage = 'An error occurred during authentication';
}
toast.error(errorMessage);
window.history.replaceState({}, document.title, window.location.pathname);
}
}, [arcadeAuthSuccess, toolkit, authId, error, createArcadeConnection, refetchArcadeConnections]);
const disconnectAccount = async (connectionId: string) => {
await deleteConnection(
{ connectionId },
Expand All @@ -55,10 +97,7 @@ export default function ConnectionsPage() {

return (
<div className="grid gap-6">
<SettingsCard
title={m['pages.settings.connections.title']()}
description={m['pages.settings.connections.description']()}
>
<SettingsCard title="Email Connections" description="Connect your email accounts to Zero.">
<div className="space-y-6">
{isLoading ? (
<div className="grid gap-4 md:grid-cols-3">
Expand Down Expand Up @@ -228,6 +267,123 @@ export default function ConnectionsPage() {
</div>
</div>
</SettingsCard>

<SettingsCard
title="Arcade Integrations"
description="Connect to external services through Arcade to enhance Zero Mail with AI-powered tools."
>
<div className="space-y-6">
{arcadeLoading ? (
<div className="grid gap-4 md:grid-cols-3">
{[...Array(3)].map((n) => (
<div
key={n}
className="bg-popover flex items-center justify-between rounded-lg border p-4"
>
<div className="flex min-w-0 items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-full lg:w-32" />
<Skeleton className="h-3 w-full lg:w-48" />
</div>
</div>
<Skeleton className="ml-4 h-8 w-8 rounded-full" />
</div>
))}
</div>
) : arcadeConnections.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2">
{arcadeConnections.map((connection) => (
<div
key={connection.id}
className="bg-popover flex items-center justify-between rounded-lg border p-4"
>
<div className="flex min-w-0 items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-lg">
<Sparkles className="size-6" />
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-sm font-medium capitalize">
{connection.toolkit}
</span>
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">
Connected
</Badge>
<span className="text-muted-foreground text-xs">
{connection.authorizedAt &&
new Date(connection.authorizedAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-primary ml-4 shrink-0"
>
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent showOverlay>
<DialogHeader>
<DialogTitle>Disconnect {connection.toolkit}</DialogTitle>
<DialogDescription>
Are you sure you want to disconnect this integration?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={async () => {
try {
await revokeAuthorization({ id: connection.id });
toast.success('Integration disconnected');
void refetchArcadeConnections();
} catch {
toast.error('Failed to disconnect integration');
}
}}
>
Disconnect
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
</div>
))}
</div>
) : (
<div className="text-muted-foreground py-8 text-center">
<Sparkles className="mx-auto mb-4 h-12 w-12 opacity-50" />
<p className="text-sm">No integrations connected</p>
<p className="mt-1 text-xs">
Connect to external services to access powerful AI tools
</p>
</div>
)}

<div className="flex items-center justify-start">
<AddArcadeConnectionDialog onSuccess={() => void refetchArcadeConnections()}>
<Button
variant="outline"
className="group relative w-9 overflow-hidden duration-200 hover:w-full sm:hover:w-[32.5%]"
>
<Plus className="absolute left-2 h-4 w-4" />
<span className="whitespace-nowrap pl-7 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
Add Integration
</span>
</Button>
</AddArcadeConnectionDialog>
</div>
</div>
</SettingsCard>
</div>
);
}
124 changes: 124 additions & 0 deletions apps/mail/components/ai/arcade-auth-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Button } from '../ui/button';

interface ArcadeAuthDialogProps {
toolName: string | null;
onAuthorized?: () => void;
onCancel?: () => void;
}

export function ArcadeAuthDialog({ toolName, onAuthorized, onCancel }: ArcadeAuthDialogProps) {
const [isWaiting, setIsWaiting] = useState(false);
const [authId, setAuthId] = useState<string | null>(null);
const trpc = useTRPC();

// Check if tool needs authorization
const { data: authStatus } = useQuery(
trpc.arcadeConnections.checkAuthorization.queryOptions(
{ toolName: toolName || '' },
{
enabled: !!toolName,
},
),
);

// Wait for authorization mutation
const { mutateAsync: waitForAuth } = useMutation(
trpc.arcadeConnections.waitForAuthorization.mutationOptions(),
);

useEffect(() => {
if (authStatus?.authId && authStatus.authId !== authId) {
setAuthId(authStatus.authId);
}
}, [authStatus?.authId, authId]);

const handleAuthorize = () => {
if (authStatus?.authUrl) {
window.open(authStatus.authUrl, '_blank');
setIsWaiting(true);

// Start polling for authorization completion
if (authId) {
waitForAuth({ authId })
.then((result) => {
if (result.success) {
onAuthorized?.();
}
})
.catch(() => {
setIsWaiting(false);
});
}
}
};

const handleCancel = () => {
setIsWaiting(false);
onCancel?.();
};

if (!toolName || !authStatus?.needsAuth) {
return null;
}

return (
<Dialog
open={!!toolName && authStatus.needsAuth}
onOpenChange={(open) => !open && handleCancel()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Authorization Required</DialogTitle>
<DialogDescription>
The AI assistant needs your permission to use <strong>{toolName}</strong>. This will
allow the assistant to perform actions on your behalf.
</DialogDescription>
</DialogHeader>

<div className="py-4">
{isWaiting ? (
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">
Waiting for authorization... Please complete the authorization in the new window.
</p>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm">
Click the button below to authorize this tool. A new window will open where you can
grant the necessary permissions.
</p>

{authStatus.error && <p className="text-destructive text-sm">{authStatus.error}</p>}
</div>
)}
</div>

<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={isWaiting}>
Cancel
</Button>
{!isWaiting && (
<Button onClick={handleAuthorize}>
<ExternalLink className="mr-2 h-4 w-4" />
Authorize {toolName}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading