feat: implement Trello integration with OAuth and user fields (#48)

* feat: implement Trello integration with OAuth and user fields

* feat: migrate Trello integration to new integrations table

* refactor: migrate Trello integration to use new integration system

* refactor: migrate Trello integration to use new integrations table

* fix: add loading check for Trello integration and update VSCode settings

* feat: enhance import boards UI with dynamic integration providers and local storage config

* feat: add select/unselect all buttons and loading state to board import form

* feat: add Trello import env vars on README

* docs: update Trello import guide to use OAuth flow instead of API keys

* feat: add integrations table for OAuth token management

* feat: connect trello from import modal

* refactor: consolidate Trello integration endpoints into unified integration router

* fix: trello import again

* refactor: generalize integration authorization flow and improve error handling

* refactor: update Trello API endpoints and tags for better organization

* feat: add Integrations tag to OpenAPI specification

* refactor: update Trello auth endpoint to use generic integration provider param

* refactor: move Trello integration logic from trello.ts to import.ts router

* refactor: consolidate Trello integration endpoints and fix API URL

* chore: remove debug logs

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
LovelessCodes
2025-06-10 20:24:18 +02:00
committed by GitHub
parent dd6cdfc208
commit 75e89f118f
22 changed files with 2830 additions and 154 deletions

View File

@@ -22,6 +22,8 @@ export const env = createEnv({
)
.optional(),
POSTGRES_URL: z.string().url(),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),

View File

@@ -5,6 +5,6 @@ import { createDrizzleClient } from "@kan/db/client";
export const config = { api: { bodyParser: false } };
const auth = initAuth(createDrizzleClient());
export const auth = initAuth(createDrizzleClient());
export default toNodeHandler(auth.handler);

View File

@@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const { user } = await createNextApiContext(req);
if (!user)
return res.status(401).json({ message: "User not authenticated" });
const apiKey = process.env.TRELLO_APP_API_KEY;
if (!apiKey)
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
const token = req.body.token;
if (!token)
return res.status(400).json({ message: "No token found" });
try {
const { db } = await createNextApiContext(req);
await db.insert(integrations).values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
}).onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
return res.status(200).json({ message: "Trello authentication successful" });
} catch (err) {
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
export default function TrelloAuthorize() {
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
console.log("Posting token to /api/trello/authenticate", token);
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
return <div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>;
}

View File

@@ -1,37 +1,68 @@
import Link from "next/link";
import { Listbox, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import {
HiChevronUpDown,
HiMiniArrowTopRightOnSquare,
HiOutlineQuestionMarkCircle,
HiXMark,
} from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
interface TrelloFormValues {
apiKey: string;
token: string;
}
const sources = [{ source: "Trello" }];
const integrationProviders: Record<
string,
{ name: string; icon: JSX.Element }
> = {
trello: {
name: "Trello",
icon: <FaTrello />,
},
};
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery();
const { control, handleSubmit } = useForm({
defaultValues: {
source: "Trello",
source: integrations?.[0]?.provider ?? "trello",
},
});
const { data: trelloUrl } = api.integration.getAuthorizationUrl.useQuery(
{ provider: "trello" },
{
enabled: !integrations?.some(
(integration) => integration.provider === "trello",
),
},
);
const hasIntegrations = integrations && integrations.length > 0;
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
const onSubmit = () => {
handleNextStep();
if (!hasIntegrations && trelloUrl) {
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else {
handleNextStep();
}
};
return (
@@ -47,9 +78,9 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
<div className="relative">
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<FaTrello />
{integrationProviders[field.value]?.icon}
<span className="ml-2 block truncate">
{field.value}
{integrationProviders[field.value]?.name}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
@@ -68,20 +99,41 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{sources.map(({ source }, index) => (
{hasIntegrations ? (
integrations.map((integration, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
value={integration.provider}
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
{
integrationProviders[integration.provider]
?.icon
}
<span className="ml-2 block truncate font-normal">
{
integrationProviders[integration.provider]
?.name
}
</span>
</div>
</Listbox.Option>
))
) : (
<Listbox.Option
key={`source_${index}`}
key="trello_placeholder"
className="relative cursor-default select-none px-1"
value={source}
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
<FaTrello className="ml-1" />
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate font-normal">
{source}
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
))}
)}
</Listbox.Options>
</Transition>
</div>
@@ -94,7 +146,14 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit">Select source</Button>
<Button
type="submit"
iconRight={
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? "Select source" : "Connect Trello"}
</Button>
</div>
</div>
</form>
@@ -103,25 +162,26 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const ImportTrello: React.FC = () => {
const utils = api.useUtils();
const [apiKey, setApiKey] = useState("");
const [token, setToken] = useState("");
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
const refetchBoards = () => utils.board.all.refetch();
const boards = api.import.trello.getBoards.useQuery(
{ apiKey, token },
{
enabled: apiKey && token ? true : false,
},
);
const { data: boards, isLoading: boardsLoading } =
api.import.trello.getBoards.useQuery();
const handleSetAuthDetails = (apiKey: string, token: string) => {
setApiKey(apiKey);
setToken(token);
};
const {
register: registerBoards,
handleSubmit: handleSubmitBoards,
setValue,
watch,
} = useForm({
defaultValues: Object.fromEntries(
boards?.map((board) => [board.id, true]) ?? [],
),
});
const importBoards = api.import.trello.importBoards.useMutation({
onSuccess: async () => {
@@ -146,83 +206,94 @@ const ImportTrello: React.FC = () => {
},
});
const { register, handleSubmit } = useForm<TrelloFormValues>({
defaultValues: {
apiKey: "",
token: "",
},
});
const onSubmit = (values: TrelloFormValues) => {
handleSetAuthDetails(values.apiKey, values.token);
};
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
useForm({
defaultValues: Object.fromEntries(
boards.data?.map((board) => [board.id, true]) ?? [],
),
});
const boardWatchers = boards?.map((board) => ({
id: board.id,
value: watch(board.id),
}));
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
importBoards.mutate({
boardIds,
apiKey,
token,
workspacePublicId: workspace.publicId,
});
};
if (boards.data?.length)
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">
{boards.data.map((board) => (
<div key={board.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={board.id}
>
<input
id={board.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerBoards(board.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{board.name}
</span>
</label>
</div>
))}
const renderContent = () => {
if (boardsLoading) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
</div>
);
}
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={importBoards.isPending}>
Import boards
</Button>
</div>
if (!boards?.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
No boards found
</p>
</div>
</form>
);
);
}
return boards.map((board) => (
<div key={board.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={board.id}
>
<input
id={board.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerBoards(board.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{board.name}
</span>
</label>
</div>
));
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="text-neutral-900 dark:text-dark-1000"
>
<div className="space-y-4 px-5">
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
<Input id="token" placeholder="Token" {...register("token")} />
</div>
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={boards.isLoading}>
Fetch boards
<Toggle
label="Select all"
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const board of boards || []) {
setValue(board.id, newState);
}
}}
/>
<div className="space-x-2">
<Button
type="submit"
isLoading={importBoards.isPending}
disabled={
importBoards.isPending ||
boardsLoading ||
!boards?.length ||
!boards.some(
(board) =>
boardWatchers?.find((w) => w.id === board.id)?.value === true,
)
}
>
Import boards (
{boardWatchers?.filter((w) => w.value === true).length || 0})
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -6,6 +7,7 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import Avatar from "./components/Avatar";
@@ -22,9 +24,54 @@ export default function SettingsPage() {
const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const { showPopup } = usePopup();
const { data } = api.user.getUser.useQuery();
const {
data: integrations,
refetch: refetchIntegrations,
isLoading: integrationsLoading,
} = api.integration.providers.useQuery();
const { data: trelloUrl, refetch: refetchTrelloUrl } =
api.integration.getAuthorizationUrl.useQuery({ provider: "trello" }, {
enabled:
!integrationsLoading &&
!integrations?.some((integration) => integration.provider === "trello"),
refetchOnWindowFocus: true,
});
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } = api.integration.disconnect.useMutation({
onSuccess: () => {
refetchUser();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: "Trello disconnected",
message: "Your Trello account has been disconnected.",
icon: "success",
});
},
onError: () => {
showPopup({
header: "Error disconnecting Trello",
message: "An error occurred while disconnecting your Trello account.",
icon: "error",
});
},
});
const refetchUser = () => utils.user.getUser.refetch();
const handleOpenBillingPortal = async () => {
@@ -115,6 +162,48 @@ export default function SettingsPage() {
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Trello
</h2>
{!integrations?.some(
(integration) => integration.provider === "trello",
) && trelloUrl ? (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Connect your Trello account to import boards.
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={() =>
window.open(
trelloUrl.url,
"trello_auth",
"height=800,width=600",
)
}
>
Connect Trello
</Button>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
You are already connected to Trello.
</p>
<Button
variant="primary"
onClick={() => {
disconnectTrello({ provider: "trello" });
}}
>
Disconnect Trello
</Button>
</>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
API keys