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:
@@ -20,6 +20,9 @@ S3_SECRET_ACCESS_KEY=
|
|||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
|
|
||||||
|
TRELLO_APP_API_KEY=
|
||||||
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ pnpm dev
|
|||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
|
|||||||
@@ -3,31 +3,25 @@ title: Trello
|
|||||||
description: "Import your Trello boards to Kan."
|
description: "Import your Trello boards to Kan."
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting your Trello API key and token
|
## Authorizing Kan.bn for Trello
|
||||||
|
|
||||||
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
|
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes):
|
||||||
|
|
||||||
1. **Sign in to your Trello account**
|
1. **Authorize Kan.bn for Trello**
|
||||||
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
|
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
|
||||||
|
|
||||||
2. **Create a new integration**
|
2. **Log in to your Trello account**
|
||||||
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
|
You will be redirected to Trello to log in to your Trello account.
|
||||||
|
|
||||||
- **Name**: Choose a name for your integration (e.g. "Kan Import")
|
3. **Authorize Kan.bn for Trello**
|
||||||
- **Workspace**: Select the workspace containing the boards you want to import
|
Approve the permissions and you will be redirected back to Kan.bn.
|
||||||
- **Email**: Enter your email address
|
|
||||||
- **Support email**: Enter your email address
|
|
||||||
- **Author**: Add your name
|
|
||||||
|
|
||||||
3. **Get a Trello API Key**
|
4. **Verify the connection**
|
||||||
Now click **Generate a new API key** to create an API key.
|
You should see a **Disconnect Trello** button.
|
||||||
|
|
||||||
4. **Generate a Trello Token**
|
|
||||||
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
|
|
||||||
|
|
||||||
## Importing your Trello boards
|
## Importing your Trello boards
|
||||||
|
|
||||||
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
|
Once you have authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps:
|
||||||
|
|
||||||
1. **Create a new import in Kan**
|
1. **Create a new import in Kan**
|
||||||
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
||||||
@@ -35,8 +29,5 @@ Once you have your API key and token, importing your Trello boards is easy. Foll
|
|||||||
2. **Select Trello as the Import Source**
|
2. **Select Trello as the Import Source**
|
||||||
Choose **Trello** from the list of import options and click **Select source**.
|
Choose **Trello** from the list of import options and click **Select source**.
|
||||||
|
|
||||||
3. **Enter Your Trello API Key and Token**
|
3. **Start the Import**
|
||||||
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
|
|
||||||
|
|
||||||
4. **Start the Import**
|
|
||||||
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const env = createEnv({
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
POSTGRES_URL: z.string().url(),
|
POSTGRES_URL: z.string().url(),
|
||||||
|
TRELLO_APP_API_KEY: z.string().optional(),
|
||||||
|
TRELLO_APP_SECRET: z.string().optional(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ import { createDrizzleClient } from "@kan/db/client";
|
|||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
export default toNodeHandler(auth.handler);
|
export default toNodeHandler(auth.handler);
|
||||||
|
|||||||
51
apps/web/src/pages/api/trello/authenticate.ts
Normal file
51
apps/web/src/pages/api/trello/authenticate.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal file
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,37 +1,68 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
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 { Controller, useForm } from "react-hook-form";
|
||||||
import { FaTrello } from "react-icons/fa";
|
import { FaTrello } from "react-icons/fa";
|
||||||
import {
|
import {
|
||||||
HiChevronUpDown,
|
HiChevronUpDown,
|
||||||
|
HiMiniArrowTopRightOnSquare,
|
||||||
HiOutlineQuestionMarkCircle,
|
HiOutlineQuestionMarkCircle,
|
||||||
HiXMark,
|
HiXMark,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Toggle from "~/components/Toggle";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface TrelloFormValues {
|
const integrationProviders: Record<
|
||||||
apiKey: string;
|
string,
|
||||||
token: string;
|
{ name: string; icon: JSX.Element }
|
||||||
}
|
> = {
|
||||||
|
trello: {
|
||||||
const sources = [{ source: "Trello" }];
|
name: "Trello",
|
||||||
|
icon: <FaTrello />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||||
|
const { data: integrations, refetch: refetchIntegrations } =
|
||||||
|
api.integration.providers.useQuery();
|
||||||
const { control, handleSubmit } = useForm({
|
const { control, handleSubmit } = useForm({
|
||||||
defaultValues: {
|
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 = () => {
|
const onSubmit = () => {
|
||||||
handleNextStep();
|
if (!hasIntegrations && trelloUrl) {
|
||||||
|
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||||
|
} else {
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +78,9 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
<div className="relative">
|
<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">
|
<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">
|
<span className="flex items-center">
|
||||||
<FaTrello />
|
{integrationProviders[field.value]?.icon}
|
||||||
<span className="ml-2 block truncate">
|
<span className="ml-2 block truncate">
|
||||||
{field.value}
|
{integrationProviders[field.value]?.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<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"
|
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">
|
<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
|
<Listbox.Option
|
||||||
key={`source_${index}`}
|
key="trello_placeholder"
|
||||||
className="relative cursor-default select-none px-1"
|
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">
|
<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">
|
<span className="ml-2 block truncate font-normal">
|
||||||
{source}
|
{integrationProviders.trello?.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
))}
|
)}
|
||||||
</Listbox.Options>
|
</Listbox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</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 className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit">Select source</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
iconRight={
|
||||||
|
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasIntegrations ? "Select source" : "Connect Trello"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -103,25 +162,26 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
|
|
||||||
const ImportTrello: React.FC = () => {
|
const ImportTrello: React.FC = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||||
|
|
||||||
const refetchBoards = () => utils.board.all.refetch();
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
const boards = api.import.trello.getBoards.useQuery(
|
const { data: boards, isLoading: boardsLoading } =
|
||||||
{ apiKey, token },
|
api.import.trello.getBoards.useQuery();
|
||||||
{
|
|
||||||
enabled: apiKey && token ? true : false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetAuthDetails = (apiKey: string, token: string) => {
|
const {
|
||||||
setApiKey(apiKey);
|
register: registerBoards,
|
||||||
setToken(token);
|
handleSubmit: handleSubmitBoards,
|
||||||
};
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: Object.fromEntries(
|
||||||
|
boards?.map((board) => [board.id, true]) ?? [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const importBoards = api.import.trello.importBoards.useMutation({
|
const importBoards = api.import.trello.importBoards.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -146,83 +206,94 @@ const ImportTrello: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<TrelloFormValues>({
|
const boardWatchers = boards?.map((board) => ({
|
||||||
defaultValues: {
|
id: board.id,
|
||||||
apiKey: "",
|
value: watch(board.id),
|
||||||
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 onSubmitBoards = (values: Record<string, boolean>) => {
|
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||||
|
|
||||||
importBoards.mutate({
|
importBoards.mutate({
|
||||||
boardIds,
|
boardIds,
|
||||||
apiKey,
|
|
||||||
token,
|
|
||||||
workspacePublicId: workspace.publicId,
|
workspacePublicId: workspace.publicId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (boards.data?.length)
|
const renderContent = () => {
|
||||||
return (
|
if (boardsLoading) {
|
||||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
return (
|
||||||
<div className="h-[105px] overflow-scroll px-5">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||||
{boards.data.map((board) => (
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
<div key={board.id}>
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
<label
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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">
|
if (!boards?.length) {
|
||||||
<div>
|
return (
|
||||||
<Button type="submit" isLoading={importBoards.isPending}>
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
Import boards
|
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||||
</Button>
|
No boards found
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<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 className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<div>
|
<Toggle
|
||||||
<Button type="submit" isLoading={boards.isLoading}>
|
label="Select all"
|
||||||
Fetch boards
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
@@ -6,6 +7,7 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import Avatar from "./components/Avatar";
|
import Avatar from "./components/Avatar";
|
||||||
@@ -22,9 +24,54 @@ export default function SettingsPage() {
|
|||||||
const { modalContentType, openModal } = useModal();
|
const { modalContentType, openModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const { data } = api.user.getUser.useQuery();
|
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 refetchUser = () => utils.user.getUser.refetch();
|
||||||
|
|
||||||
const handleOpenBillingPortal = async () => {
|
const handleOpenBillingPortal = async () => {
|
||||||
@@ -115,6 +162,48 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</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">
|
<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">
|
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
API keys
|
API keys
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ services:
|
|||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||||
|
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
||||||
docsUrl: "docs.kan.bn",
|
docsUrl: "docs.kan.bn",
|
||||||
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
|
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { boardRouter } from "./routers/board";
|
|||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
import { feedbackRouter } from "./routers/feedback";
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
import { importRouter } from "./routers/import";
|
import { importRouter } from "./routers/import";
|
||||||
|
import { integrationRouter } from "./routers/integration";
|
||||||
import { labelRouter } from "./routers/label";
|
import { labelRouter } from "./routers/label";
|
||||||
import { listRouter } from "./routers/list";
|
import { listRouter } from "./routers/list";
|
||||||
import { memberRouter } from "./routers/member";
|
import { memberRouter } from "./routers/member";
|
||||||
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
import: importRouter,
|
import: importRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
workspace: workspaceRouter,
|
workspace: workspaceRouter,
|
||||||
|
integration: integrationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as boardRepo from "@kan/db/repository/board.repo";
|
|||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
import * as importRepo from "@kan/db/repository/import.repo";
|
import * as importRepo from "@kan/db/repository/import.repo";
|
||||||
|
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||||
import * as listRepo from "@kan/db/repository/list.repo";
|
import * as listRepo from "@kan/db/repository/list.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
@@ -13,10 +14,9 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
import { apiKeys, urls } from "./integration";
|
||||||
|
|
||||||
const TRELLO_API_URL = "https://api.trello.com/1";
|
export interface TrelloBoard {
|
||||||
|
|
||||||
interface TrelloBoard {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
@@ -42,10 +42,6 @@ interface TrelloCard {
|
|||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberData {
|
|
||||||
idBoards: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const importRouter = createTRPCRouter({
|
export const importRouter = createTRPCRouter({
|
||||||
trello: createTRPCRouter({
|
trello: createTRPCRouter({
|
||||||
getBoards: protectedProcedure
|
getBoards: protectedProcedure
|
||||||
@@ -53,46 +49,51 @@ export const importRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Get boards from Trello",
|
summary: "Get boards from Trello",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/imports/trello/boards",
|
path: "/integrations/trello/boards",
|
||||||
description: "Retrieves all boards from Trello",
|
description: "Retrieves all boards from Trello",
|
||||||
tags: ["Imports"],
|
tags: ["Integrations"],
|
||||||
protect: true,
|
protect: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
apiKey: z.string().length(32),
|
|
||||||
token: z.string().length(76),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ ctx }) => {
|
||||||
const fetchMemberRes = await fetch(
|
const apiKey = apiKeys.trello;
|
||||||
`${TRELLO_API_URL}/tokens/${input.token}/member?key=${input.apiKey}`,
|
|
||||||
|
if (!apiKey)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello API key not found",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
"trello",
|
||||||
);
|
);
|
||||||
|
|
||||||
const member = (await fetchMemberRes.json()) as MemberData;
|
const token = integration?.accessToken;
|
||||||
|
|
||||||
const boardIds = member.idBoards;
|
if (!token)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello token not found",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
const fetchBoard = async (boardId: string) => {
|
const response = await fetch(
|
||||||
const response = await fetch(
|
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
|
||||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
|
);
|
||||||
);
|
|
||||||
const data = (await response.json()) as TrelloBoard;
|
|
||||||
|
|
||||||
return data;
|
const data = (await response.json()) as TrelloBoard[];
|
||||||
};
|
|
||||||
|
|
||||||
const boards = [];
|
return data.map((board) => ({
|
||||||
|
|
||||||
for (const boardId of boardIds) {
|
|
||||||
boards.push(Promise.resolve(fetchBoard(boardId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardDataArray = await Promise.all(boards);
|
|
||||||
|
|
||||||
return boardDataArray.map((board) => ({
|
|
||||||
id: board.id,
|
id: board.id,
|
||||||
name: board.name,
|
name: board.name,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +103,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Import boards from Trello",
|
summary: "Import boards from Trello",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/imports/trello/import",
|
path: "/imports/trello/boards",
|
||||||
description: "Imports boards from Trello",
|
description: "Imports boards from Trello",
|
||||||
tags: ["Imports"],
|
tags: ["Imports"],
|
||||||
protect: true,
|
protect: true,
|
||||||
@@ -111,8 +112,6 @@ export const importRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
boardIds: z.array(z.string()),
|
boardIds: z.array(z.string()),
|
||||||
apiKey: z.string().length(32),
|
|
||||||
token: z.string().length(76),
|
|
||||||
workspacePublicId: z.string().min(12),
|
workspacePublicId: z.string().min(12),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -120,12 +119,32 @@ export const importRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.user?.id;
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
const apiKey = apiKeys.trello;
|
||||||
|
|
||||||
|
if (!apiKey)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello API key not found",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
if (!userId)
|
if (!userId)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
message: `User not authenticated`,
|
message: `User not authenticated`,
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
"trello",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello token not found",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.workspacePublicId,
|
input.workspacePublicId,
|
||||||
@@ -150,7 +169,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const boardId of input.boardIds) {
|
for (const boardId of input.boardIds) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}&lists=open&cards=open&labels=all`,
|
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`,
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as TrelloBoard;
|
const data = (await response.json()) as TrelloBoard;
|
||||||
|
|
||||||
|
|||||||
128
packages/api/src/routers/integration.ts
Normal file
128
packages/api/src/routers/integration.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const urls = {
|
||||||
|
trello: "https://api.trello.com/1",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiKeys = {
|
||||||
|
trello: process.env.TRELLO_APP_API_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const integrationRouter = createTRPCRouter({
|
||||||
|
providers: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integrations = await integrationsRepo.getProvidersForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return integrations;
|
||||||
|
}),
|
||||||
|
disconnect: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Disconnect integration",
|
||||||
|
method: "POST",
|
||||||
|
path: "/integration/disconnect",
|
||||||
|
description: "Disconnects an integration",
|
||||||
|
tags: ["Integration"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(z.object({ provider: z.enum(["trello"]) }))
|
||||||
|
.output(z.object({}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
input.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Integration not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
|
||||||
|
await integrationsRepo.deleteProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
input.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
getAuthorizationUrl: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get authorization URL for an integration",
|
||||||
|
method: "GET",
|
||||||
|
path: "/integration/authorize",
|
||||||
|
description: "Retrieves the authorization URL for an integration",
|
||||||
|
tags: ["Integration"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(z.object({ provider: z.enum(["trello"]) }))
|
||||||
|
.output(z.object({ url: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const apiKey = apiKeys[input.provider];
|
||||||
|
|
||||||
|
if (!apiKey)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} API key not set in environment variables`,
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
input.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} integration already exists`,
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.provider === "trello") {
|
||||||
|
const url = `${urls[input.provider]}/authorize?key=${apiKey}&expiration=never&response_type=token&scope=read&return_url=${env("NEXT_PUBLIC_BASE_URL")}/settings/trello/authorize&callback_method=fragment`;
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Invalid provider",
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "integration" (
|
||||||
|
"provider" varchar(255) NOT NULL,
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"accessToken" varchar(255) NOT NULL,
|
||||||
|
"refreshToken" varchar(255),
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"createdAt" timestamp NOT NULL,
|
||||||
|
"updatedAt" timestamp,
|
||||||
|
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
2164
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
2164
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1749405288761,
|
"when": 1749405288761,
|
||||||
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
|
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749377372062,
|
||||||
|
"tag": "20250608100932_AddIntegrationsTable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
59
packages/db/src/repository/integration.repo.ts
Normal file
59
packages/db/src/repository/integration.repo.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { and, eq, gte } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import { integrations } from "@kan/db/schema";
|
||||||
|
|
||||||
|
export const isProviderAvailableForUser = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
provider: string,
|
||||||
|
) => {
|
||||||
|
const integration = await db.query.integrations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(integrations.userId, userId),
|
||||||
|
eq(integrations.provider, provider),
|
||||||
|
gte(integrations.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!integration;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProviderForUser = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
provider: string,
|
||||||
|
) => {
|
||||||
|
const integration = await db.query.integrations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(integrations.userId, userId),
|
||||||
|
eq(integrations.provider, provider),
|
||||||
|
gte(integrations.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return integration;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProvidersForUser = async (db: dbClient, userId: string) => {
|
||||||
|
const integration = await db.query.integrations.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(integrations.userId, userId),
|
||||||
|
gte(integrations.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return integration;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProviderForUser = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
provider: string,
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.delete(integrations)
|
||||||
|
.where(
|
||||||
|
and(eq(integrations.userId, userId), eq(integrations.provider, provider)),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,4 +7,5 @@ export * from "./imports";
|
|||||||
export * from "./labels";
|
export * from "./labels";
|
||||||
export * from "./lists";
|
export * from "./lists";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./integrations";
|
||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
|
|||||||
40
packages/db/src/schema/integrations.ts
Normal file
40
packages/db/src/schema/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const integrations = pgTable(
|
||||||
|
"integration",
|
||||||
|
{
|
||||||
|
provider: varchar("provider", { length: 255 }).notNull(),
|
||||||
|
userId: uuid("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: varchar("accessToken", { length: 255 }).notNull(),
|
||||||
|
refreshToken: varchar("refreshToken", { length: 255 }),
|
||||||
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
|
createdAt: timestamp("createdAt")
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({
|
||||||
|
name: "integration_pkey",
|
||||||
|
columns: [table.userId, table.provider],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
).enableRLS();
|
||||||
|
|
||||||
|
export const integrationsRelations = relations(integrations, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [integrations.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -13,6 +13,7 @@ import { cards } from "./cards";
|
|||||||
import { imports } from "./imports";
|
import { imports } from "./imports";
|
||||||
import { lists } from "./lists";
|
import { lists } from "./lists";
|
||||||
import { workspaceMembers, workspaces } from "./workspaces";
|
import { workspaceMembers, workspaces } from "./workspaces";
|
||||||
|
import { integrations } from "./integrations";
|
||||||
|
|
||||||
export const users = pgTable("user", {
|
export const users = pgTable("user", {
|
||||||
id: uuid("id")
|
id: uuid("id")
|
||||||
@@ -55,6 +56,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
relationName: "workspaceCreatedByUser",
|
relationName: "workspaceCreatedByUser",
|
||||||
}),
|
}),
|
||||||
apiKeys: many(apikey),
|
apiKeys: many(apikey),
|
||||||
|
integrations: many(integrations),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersToWorkspacesRelations = relations(
|
export const usersToWorkspacesRelations = relations(
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
},
|
},
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
|
"TRELLO_APP_API_KEY",
|
||||||
|
"TRELLO_APP_SECRET",
|
||||||
"GOOGLE_CLIENT_ID",
|
"GOOGLE_CLIENT_ID",
|
||||||
"GOOGLE_CLIENT_SECRET",
|
"GOOGLE_CLIENT_SECRET",
|
||||||
"DISCORD_CLIENT_ID",
|
"DISCORD_CLIENT_ID",
|
||||||
|
|||||||
Reference in New Issue
Block a user