slack-integration-card.tsx18.3 KBView on GitHub 'use client';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Field, FIELD_CONTROL, FieldRows, FormActions } from '@/components/ui/field';
import { Loader2, RefreshCw, Unplug, Slack, Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import { useTRPC } from '@/providers/query-provider';
import { useState, useEffect, useMemo } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
interface SlackIntegrationCardProps {
userId?: string;
/**
* The caller already draws a surface (the connections dialog, the playground's
* provider card), so the row list must not sit in a second box. Onboarding renders
* this straight onto the page background and takes the card.
*/
bare?: boolean;
}
type PatternPair = { startsWith: string; endsWith: string };
const emptyPair = (): PatternPair => ({ startsWith: '', endsWith: '' });
/**
* What `sessionStorage` was handed back after the OAuth tab closed.
*
* `JSON.parse` returns `any`, and the value came from a previous page load rather than
* from the server, so it is narrowed rather than asserted — a stale or hand-edited entry
* must fail here, not inside the connect mutation.
*/
function readPendingOAuth(raw: string): { strataServerUrl?: string } {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && 'strataServerUrl' in parsed) {
const url = parsed.strataServerUrl;
if (typeof url === 'string') return { strataServerUrl: url };
}
return {};
}
export function SlackIntegrationCard({ userId, bare }: SlackIntegrationCardProps) {
const [isConnecting, setIsConnecting] = useState(false);
const [isReinstalling, setIsReinstalling] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: integrationsData, refetch: refetchIntegrations } = useQuery(
trpc.integrations.list.queryOptions(userId ? { userId } : undefined),
);
const integration = integrationsData?.integrations.find((i) => i.id === 'slack');
const isConnected = integration?.connected || false;
const connectionId = integration?.connectionId;
const { data: connectionsData, refetch: refetchConnections } = useQuery(
trpc.connections.list.queryOptions(userId ? { userId } : undefined),
);
const slackConnection = connectionsData?.slackConnections?.[0];
const periodicSlackSyncEnabled = slackConnection?.periodicSlackSyncEnabled ?? false;
const workspaceId = slackConnection?.externalId ?? '';
const { mutateAsync: updateSlackSettings, isPending: isUpdatingSlackSettings } = useMutation(
trpc.connections.updateSlackSettings.mutationOptions(),
);
const { mutateAsync: updateConfig, isPending: isSavingPatterns } = useMutation(
trpc.integrations.slack.updateConfiguration.mutationOptions(),
);
const { data: configData } = useQuery({
...trpc.integrations.slack.getConfiguration.queryOptions({
workspaceId: workspaceId || '',
...(userId && { userId }),
}),
enabled: !!workspaceId,
});
const [patterns, setPatterns] = useState<PatternPair[]>([emptyPair()]);
const [autoSyncNewChannels, setAutoSyncNewChannels] = useState(false);
// The route parses the stored blob through `SlackConfigSchema` — including the
// bare-object-to-array coercion — so what arrives here is already the array shape.
// Only the "no patterns yet" case needs filling in, with the one blank row the
// editor starts from.
const savedPatterns = useMemo((): PatternPair[] => {
const saved = configData?.config.autoSelectPatterns ?? [];
if (saved.length === 0) return [emptyPair()];
return saved.map((p) => ({ startsWith: p.startsWith ?? '', endsWith: p.endsWith ?? '' }));
}, [configData]);
const savedAutoSync = configData?.config.autoSyncNewChannels ?? false;
useEffect(() => {
setPatterns(savedPatterns);
setAutoSyncNewChannels(savedAutoSync);
}, [savedPatterns, savedAutoSync]);
const hasPatternsChanged = useMemo(() => {
if (autoSyncNewChannels !== savedAutoSync) return true;
if (patterns.length !== savedPatterns.length) return true;
return patterns.some(
(p, i) =>
p.startsWith !== (savedPatterns[i]?.startsWith ?? '') ||
p.endsWith !== (savedPatterns[i]?.endsWith ?? ''),
);
}, [patterns, autoSyncNewChannels, savedPatterns, savedAutoSync]);
const hasAnyPattern = patterns.some((p) => p.startsWith || p.endsWith);
const updatePattern = (index: number, field: keyof PatternPair, value: string) =>
setPatterns((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
const addPattern = () => setPatterns((prev) => [...prev, emptyPair()]);
const removePattern = (index: number) =>
setPatterns((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : [emptyPair()]));
const discardPatterns = () => {
setPatterns(savedPatterns);
setAutoSyncNewChannels(savedAutoSync);
};
const handleSavePatterns = async () => {
if (!workspaceId || !configData) {
toast.error('Workspace ID not found');
return;
}
try {
const existingConfig = configData.config;
const nonEmptyPatterns = patterns
.map((p) => ({
...(p.startsWith ? { startsWith: p.startsWith } : {}),
...(p.endsWith ? { endsWith: p.endsWith } : {}),
}))
.filter((p) => p.startsWith !== undefined || p.endsWith !== undefined);
await updateConfig({
workspaceId,
config: {
selectedChannelIds: existingConfig.selectedChannelIds,
blockedChannelIds: existingConfig.blockedChannelIds,
autoSelectPatterns: nonEmptyPatterns,
autoSyncNewChannels: nonEmptyPatterns.length === 0 ? false : autoSyncNewChannels,
// Carried through rather than omitted: the schema defaults it to `true`, so
// leaving it out would silently re-admit external DMs for anyone who turned
// them off, every time they edited a channel pattern.
autoSyncExternalDms: existingConfig.autoSyncExternalDms,
},
...(userId && { userId }),
});
await queryClient.invalidateQueries({
queryKey=[redacted]
workspaceId,
...(userId && { userId }),
}),
});
} catch (error) {
toast.error(
`Failed to save patterns: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const handlePeriodicSyncChange = async (checked: boolean) => {
if (!connectionId) {
toast.error('Connection ID not found');
return;
}
try {
await updateSlackSettings({
connectionId,
settings: { periodicSlackSyncEnabled: checked },
userId,
});
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
toast.error(
`Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const { mutateAsync: initiateOAuth } = useMutation(
trpc.integrations.initiateOAuth.mutationOptions(),
);
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
const handleReinstall = async () => {
try {
setIsReinstalling(true);
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: 'slack',
userId,
});
if (!oauthUrl) {
setIsReinstalling(false);
toast.error('Failed to get OAuth URL');
return;
}
sessionStorage.setItem('slack_oauth_reinstall_pending', JSON.stringify({ strataServerUrl }));
window.open(oauthUrl, '_blank');
} catch (error) {
console.error('[SlackIntegrationCard] Error reinstalling Slack app:', error);
setIsReinstalling(false);
toast.error('Failed to reinstall Slack app');
}
};
// Handle OAuth callback
useEffect(() => {
const handleWindowFocus = async () => {
// Handle reinstall callback
const reinstallPending = sessionStorage.getItem('slack_oauth_reinstall_pending');
if (reinstallPending && isReinstalling) {
try {
const { strataServerUrl } = readPendingOAuth(reinstallPending);
await connect({
providerId: 'slack',
connectionParams: { strataServerUrl },
userId,
});
sessionStorage.removeItem('slack_oauth_reinstall_pending');
setIsReinstalling(false);
void refetchIntegrations();
void refetchConnections();
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isExpectedError =
errorMessage.includes('not authenticated') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Please complete OAuth flow');
if (!isExpectedError) {
sessionStorage.removeItem('slack_oauth_reinstall_pending');
setIsReinstalling(false);
toast.error(`Failed to reinstall Slack: ${errorMessage}`);
}
}
return;
}
const storageKey=[redacted];
const pendingAuth = sessionStorage.getItem(storageKey);
if (pendingAuth && isConnecting) {
try {
const { strataServerUrl } = readPendingOAuth(pendingAuth);
await connect({
providerId: 'slack',
connectionParams: { strataServerUrl },
userId,
});
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
void refetchIntegrations();
void refetchConnections();
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Ignore expected auth-in-progress errors
const isExpectedError =
errorMessage.includes('not authenticated') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Please complete OAuth flow');
if (!isExpectedError) {
console.error('[SlackIntegrationCard] Error connecting Slack:', error);
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
toast.error(`Failed to connect Slack: ${errorMessage}`);
}
}
}
};
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [
isConnecting,
isReinstalling,
connect,
refetchIntegrations,
refetchConnections,
trpc,
userId,
queryClient,
]);
const handleConnect = async () => {
try {
setIsConnecting(true);
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: 'slack',
userId,
});
if (!oauthUrl) {
setIsConnecting(false);
toast.error('Failed to get OAuth URL');
return;
}
sessionStorage.setItem('slack_oauth_pending', JSON.stringify({ strataServerUrl }));
window.open(oauthUrl, '_blank');
} catch (error) {
console.error('[SlackIntegrationCard] Error initiating auth for Slack:', error);
setIsConnecting(false);
toast.error('Failed to initiate connection for Slack');
}
};
const handleDisconnect = async () => {
try {
setIsDisconnecting(true);
if (connectionId) {
await deleteConnection({
connectionId,
userId,
});
void refetchIntegrations();
void refetchConnections();
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
} else {
toast.error('Could not find connection ID to disconnect.');
}
} catch (error) {
console.error('[SlackIntegrationCard] Error disconnecting Slack:', error);
toast.error('Failed to disconnect Slack');
} finally {
setIsDisconnecting(false);
}
};
if (!isConnected) {
return (
<div className="flex flex-col items-start gap-3">
<p className="text-muted-foreground text-sm">
Cedar syncs the channels you choose and surfaces what was said in them on the deals they
belong to.
</p>
<Button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? <Loader2 className="animate-spin" /> : <Slack />}
{isConnecting ? 'Connecting…' : 'Connect Slack'}
</Button>
</div>
);
}
return (
<FieldRows {...(bare ? { bare: true } : {})}>
<Field
label="Periodic message sync"
hint="Keeps synced channels up to date in the background."
>
<Switch
checked={periodicSlackSyncEnabled}
disabled={isUpdatingSlackSettings}
onCheckedChange={handlePeriodicSyncChange}
aria-label="Periodic message sync"
/>
</Field>
<Field
label="Channel patterns"
stacked
hint="Match a channel name by prefix, suffix, or both — “ext-”, “-cedar”."
>
<div className="flex flex-col items-start gap-2">
{patterns.map((pair, index) => (
<div key=[redacted] className="flex w-full items-center gap-2">
<Input
value={pair.startsWith}
onChange={(e) => updatePattern(index, 'startsWith', e.target.value)}
placeholder="Starts with"
aria-label={`Pattern ${index + 1} starts with`}
className={`${FIELD_CONTROL} flex-1`}
/>
<Input
value={pair.endsWith}
onChange={(e) => updatePattern(index, 'endsWith', e.target.value)}
placeholder="Ends with"
aria-label={`Pattern ${index + 1} ends with`}
className={`${FIELD_CONTROL} flex-1`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removePattern(index)}
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove pattern ${index + 1}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={addPattern}>
<Plus className="h-3.5 w-3.5" />
Add pattern
</Button>
</div>
</Field>
<Field
label="Auto-sync new channels"
hint="Channels created later that match a pattern start syncing on their own."
>
<Switch
checked={autoSyncNewChannels}
onCheckedChange={setAutoSyncNewChannels}
disabled={!hasAnyPattern}
aria-label="Auto-sync new channels"
/>
</Field>
{/* The row appears and disappears as the pattern editor goes dirty, so it grows
into place rather than shunting the two rows below it down in one frame. */}
<AnimatePresence initial={false}>
{hasPatternsChanged && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<FormActions className="px-4 pb-4 pt-3">
<Button size="sm" onClick={handleSavePatterns} disabled={isSavingPatterns}>
{isSavingPatterns && <Loader2 className="animate-spin" />}
{isSavingPatterns ? 'Saving…' : 'Save changes'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={discardPatterns}
disabled={isSavingPatterns}
>
Discard
</Button>
</FormActions>
</motion.div>
)}
</AnimatePresence>
<Field
label="Reinstall Slack app"
hint="Re-runs the install to grant new permissions or repair a broken connection."
>
<Button
variant="outline"
size="sm"
onClick={handleReinstall}
disabled={isReinstalling || isDisconnecting}
>
{isReinstalling ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{isReinstalling ? 'Reinstalling…' : 'Reinstall'}
</Button>
</Field>
<Field label="Disconnect Slack" hint="Cedar stops syncing this workspace.">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isDisconnecting || isReinstalling}>
{isDisconnecting ? <Loader2 className="animate-spin" /> : <Unplug />}
{isDisconnecting ? 'Disconnecting…' : 'Disconnect'}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect Slack</DialogTitle>
<DialogDescription>
Cedar stops syncing this workspace. Messages already synced stay on the
conversations they were attached to.
</DialogDescription>
</DialogHeader>
<FormActions>
<DialogClose asChild>
<Button variant="destructive" onClick={handleDisconnect}>
Disconnect
</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
</FormActions>
</DialogContent>
</Dialog>
</Field>
</FieldRows>
);
}