feat: initial github integration with importing projects (#421)
* feat: initial github integration with importing projects * fix: remove unused args * chore: remove duplicate col --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import { Fragment, useEffect, 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 { FaGithub, FaTrello } from "react-icons/fa";
|
||||||
import {
|
import {
|
||||||
HiChevronUpDown,
|
HiChevronUpDown,
|
||||||
HiMiniArrowTopRightOnSquare,
|
HiMiniArrowTopRightOnSquare,
|
||||||
@@ -27,12 +27,23 @@ const integrationProviders: Record<
|
|||||||
name: "Trello",
|
name: "Trello",
|
||||||
icon: <FaTrello />,
|
icon: <FaTrello />,
|
||||||
},
|
},
|
||||||
|
github: {
|
||||||
|
name: "GitHub",
|
||||||
|
icon: <FaGithub />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
const SelectSource = ({
|
||||||
|
handleNextStep,
|
||||||
|
}: {
|
||||||
|
handleNextStep: (provider: string) => void;
|
||||||
|
}) => {
|
||||||
const { data: integrations, refetch: refetchIntegrations } =
|
const { data: integrations, refetch: refetchIntegrations } =
|
||||||
api.integration.providers.useQuery();
|
api.integration.providers.useQuery();
|
||||||
const { control, handleSubmit } = useForm({
|
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||||
|
api.integration.getGitHubStatus.useQuery();
|
||||||
|
|
||||||
|
const { control, handleSubmit, watch } = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
source: integrations?.[0]?.provider ?? "trello",
|
source: integrations?.[0]?.provider ?? "trello",
|
||||||
},
|
},
|
||||||
@@ -47,23 +58,36 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasIntegrations = integrations && integrations.length > 0;
|
const availableIntegrations = [
|
||||||
|
...(integrations ?? []),
|
||||||
|
...(githubStatus?.connected ? [{ provider: "github" }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasIntegrations = availableIntegrations.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
refetchIntegrations();
|
void refetchIntegrations();
|
||||||
|
void refetchGithubStatus();
|
||||||
};
|
};
|
||||||
window.addEventListener("focus", handleFocus);
|
window.addEventListener("focus", handleFocus);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("focus", handleFocus);
|
window.removeEventListener("focus", handleFocus);
|
||||||
};
|
};
|
||||||
}, [refetchIntegrations]);
|
}, [refetchIntegrations, refetchGithubStatus]);
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (!hasIntegrations && trelloUrl) {
|
const selected = watch("source");
|
||||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
if (
|
||||||
|
selected === "trello" &&
|
||||||
|
!integrations?.some((i) => i.provider === "trello")
|
||||||
|
) {
|
||||||
|
if (trelloUrl)
|
||||||
|
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||||
|
} else if (selected === "github" && !githubStatus?.connected) {
|
||||||
|
window.open("/settings/integrations", "_blank");
|
||||||
} else {
|
} else {
|
||||||
handleNextStep();
|
handleNextStep(selected);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +126,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
{hasIntegrations ? (
|
{hasIntegrations ? (
|
||||||
integrations.map((integration, index) => (
|
availableIntegrations.map((integration, index) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
key={`source_${index}`}
|
key={`source_${index}`}
|
||||||
className="relative cursor-default select-none px-1"
|
className="relative cursor-default select-none px-1"
|
||||||
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Listbox.Option
|
<>
|
||||||
key="trello_placeholder"
|
<Listbox.Option
|
||||||
className="font-sm relative cursor-default select-none px-1"
|
key="trello_placeholder"
|
||||||
value="trello"
|
className="font-sm relative cursor-default select-none px-1"
|
||||||
>
|
value="trello"
|
||||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
>
|
||||||
{integrationProviders.trello?.icon}
|
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||||
<span className="ml-2 block truncate text-sm">
|
{integrationProviders.trello?.icon}
|
||||||
{integrationProviders.trello?.name}
|
<span className="ml-2 block truncate text-sm">
|
||||||
</span>
|
{integrationProviders.trello?.name}
|
||||||
</div>
|
</span>
|
||||||
</Listbox.Option>
|
</div>
|
||||||
|
</Listbox.Option>
|
||||||
|
<Listbox.Option
|
||||||
|
key="github_placeholder"
|
||||||
|
className="font-sm relative cursor-default select-none px-1"
|
||||||
|
value="github"
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||||
|
{integrationProviders.github?.icon}
|
||||||
|
<span className="ml-2 block truncate text-sm">
|
||||||
|
{integrationProviders.github?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Listbox.Option>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Listbox.Options>
|
</Listbox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasIntegrations ? t`Select source` : t`Connect Trello`}
|
{hasIntegrations ? t`Select source` : t`Connect`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportGithub: React.FC = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { closeModal } = useModal();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||||
|
|
||||||
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
|
const { data: projects, isLoading: projectsLoading } =
|
||||||
|
api.import.github.getProjects.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register: registerProjects,
|
||||||
|
handleSubmit: handleSubmitProjects,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: Object.fromEntries(
|
||||||
|
projects?.map((project) => [project.id, true]) ?? [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importProjects = api.import.github.importProjects.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Import complete`,
|
||||||
|
message: t`Your projects have been imported.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await refetchBoards();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Import failed`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectWatchers = projects?.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
value: watch(project.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projectCount =
|
||||||
|
projectWatchers?.filter((w) => w.value === true).length ?? 0;
|
||||||
|
|
||||||
|
const onSubmitProjects = (values: Record<string, boolean>) => {
|
||||||
|
const projectIds = Object.keys(values).filter(
|
||||||
|
(key) => values[key] === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
importProjects.mutate({
|
||||||
|
projectIds,
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (projectsLoading) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projects?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`No projects found`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.map((project) => (
|
||||||
|
<div key={project.id}>
|
||||||
|
<label
|
||||||
|
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||||
|
htmlFor={project.id}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={project.id}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||||
|
{...registerProjects(project.id)}
|
||||||
|
/>
|
||||||
|
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
|
||||||
|
<div className="h-[105px] overflow-auto 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">
|
||||||
|
<Toggle
|
||||||
|
label={t`Select all`}
|
||||||
|
isChecked={!!isSelectAllEnabled}
|
||||||
|
onChange={() => {
|
||||||
|
const newState = !isSelectAllEnabled;
|
||||||
|
setIsSelectAllEnabled(newState);
|
||||||
|
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
setValue(project.id, newState);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={importProjects.isPending}
|
||||||
|
disabled={
|
||||||
|
importProjects.isPending ||
|
||||||
|
projectsLoading ||
|
||||||
|
!projects?.length ||
|
||||||
|
!projects.some(
|
||||||
|
(project) =>
|
||||||
|
projectWatchers?.find((w) => w.id === project.id)?.value ===
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
<Plural
|
||||||
|
value={projectCount}
|
||||||
|
one={`Import project (1)`}
|
||||||
|
other={`Import projects (${projectCount})`}
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
|
|||||||
value: watch(board.id),
|
value: watch(board.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
|
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
|
||||||
|
|
||||||
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);
|
||||||
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||||
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
<div className="h-[105px] overflow-auto 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 className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
|
|||||||
const newState = !isSelectAllEnabled;
|
const newState = !isSelectAllEnabled;
|
||||||
setIsSelectAllEnabled(newState);
|
setIsSelectAllEnabled(newState);
|
||||||
|
|
||||||
for (const board of boards || []) {
|
for (const board of boards ?? []) {
|
||||||
setValue(board.id, newState);
|
setValue(board.id, newState);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
|
|||||||
export function ImportBoardsForm() {
|
export function ImportBoardsForm() {
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
const [provider, setProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
|
{step === 1 && (
|
||||||
{step === 2 && <ImportTrello />}
|
<SelectSource
|
||||||
|
handleNextStep={(p) => {
|
||||||
|
setProvider(p);
|
||||||
|
setStep(step + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && provider === "trello" && <ImportTrello />}
|
||||||
|
{step === 2 && provider === "github" && <ImportGithub />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import FeedbackModal from "~/components/FeedbackModal";
|
import FeedbackModal from "~/components/FeedbackModal";
|
||||||
|
import Input from "~/components/Input";
|
||||||
import Modal from "~/components/modal";
|
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";
|
||||||
@@ -11,10 +15,28 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
const githubTokenSchema = z.object({
|
||||||
|
token: z.string().min(1, { message: t`Token is required` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
|
||||||
|
|
||||||
export default function IntegrationsSettings() {
|
export default function IntegrationsSettings() {
|
||||||
const { modalContentType, isOpen } = useModal();
|
const { modalContentType, isOpen } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isDirty, errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<GitHubTokenFormValues>({
|
||||||
|
resolver: zodResolver(githubTokenSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: integrations,
|
data: integrations,
|
||||||
refetch: refetchIntegrations,
|
refetch: refetchIntegrations,
|
||||||
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||||
|
api.integration.getGitHubStatus.useQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
refetchIntegrations();
|
void refetchIntegrations();
|
||||||
|
void refetchGithubStatus();
|
||||||
};
|
};
|
||||||
window.addEventListener("focus", handleFocus);
|
window.addEventListener("focus", handleFocus);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("focus", handleFocus);
|
window.removeEventListener("focus", handleFocus);
|
||||||
};
|
};
|
||||||
}, [refetchIntegrations]);
|
}, [refetchIntegrations, refetchGithubStatus]);
|
||||||
|
|
||||||
const { mutateAsync: disconnectTrello } =
|
const { mutateAsync: disconnectTrello } =
|
||||||
api.integration.disconnect.useMutation({
|
api.integration.disconnect.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchIntegrations();
|
void refetchIntegrations();
|
||||||
refetchTrelloUrl();
|
void refetchTrelloUrl();
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Trello disconnected`,
|
header: t`Trello disconnected`,
|
||||||
message: t`Your Trello account has been disconnected.`,
|
message: t`Your Trello account has been disconnected.`,
|
||||||
@@ -64,6 +90,49 @@ export default function IntegrationsSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
|
||||||
|
api.integration.saveGitHubToken.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void refetchGithubStatus();
|
||||||
|
reset();
|
||||||
|
showPopup({
|
||||||
|
header: t`GitHub connected`,
|
||||||
|
message: t`Your GitHub account has been connected.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Error connecting GitHub`,
|
||||||
|
message: t`An error occurred while connecting your GitHub account.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
|
||||||
|
void saveGithubToken({ token: data.token });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync: disconnectGithub } =
|
||||||
|
api.integration.disconnectGitHub.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void refetchGithubStatus();
|
||||||
|
showPopup({
|
||||||
|
header: t`GitHub disconnected`,
|
||||||
|
message: t`Your GitHub account has been disconnected.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Error disconnecting GitHub`,
|
||||||
|
message: t`An error occurred while disconnecting your GitHub account.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={t`Settings | Integrations`} />
|
<PageHead title={t`Settings | Integrations`} />
|
||||||
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
|
{t`GitHub`}
|
||||||
|
</h2>
|
||||||
|
{!githubStatus?.connected ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`Connect your GitHub account to import projects.`}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmitGithubToken)}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Personal Access Token"
|
||||||
|
{...register("token")}
|
||||||
|
errorMessage={errors.token?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || isSavingGithubToken}
|
||||||
|
isLoading={isSavingGithubToken}
|
||||||
|
>
|
||||||
|
{t`Connect GitHub`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`Your GitHub account is connected.`}
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => disconnectGithub()}>
|
||||||
|
{t`Disconnect GitHub`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Global modals */}
|
{/* Global modals */}
|
||||||
<Modal
|
<Modal
|
||||||
modalSize="md"
|
modalSize="md"
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ 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";
|
||||||
import { colours } from "@kan/shared/constants";
|
import { colours } from "@kan/shared/constants";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateSlug, generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertPermission } from "../utils/permissions";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
import { decryptToken } from "../utils/encryption";
|
||||||
import { apiKeys, urls } from "./integration";
|
import { apiKeys, urls } from "./integration";
|
||||||
|
|
||||||
export interface TrelloBoard {
|
export interface TrelloBoard {
|
||||||
@@ -50,6 +52,51 @@ interface TrelloCheckItem {
|
|||||||
pos: number;
|
pos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitHubProjectsResponse {
|
||||||
|
data: {
|
||||||
|
viewer: {
|
||||||
|
projectsV2: {
|
||||||
|
nodes?: { id: string; title: string }[];
|
||||||
|
};
|
||||||
|
organizations: {
|
||||||
|
nodes: {
|
||||||
|
projectsV2: {
|
||||||
|
nodes?: { id: string; title: string }[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubGraphQLResponse {
|
||||||
|
data?: {
|
||||||
|
node?: GitHubProjectV2Node;
|
||||||
|
};
|
||||||
|
errors?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubProjectV2Node {
|
||||||
|
title: string;
|
||||||
|
field?: {
|
||||||
|
options?: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
areaField?: {
|
||||||
|
options?: { id: string; name: string; color: string }[];
|
||||||
|
};
|
||||||
|
items?: {
|
||||||
|
nodes: {
|
||||||
|
fieldValueByName?: { name: string };
|
||||||
|
areaValue?: { name: string };
|
||||||
|
content?: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface TrelloCard {
|
interface TrelloCard {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -464,4 +511,436 @@ export const importRouter = createTRPCRouter({
|
|||||||
return { boardsCreated };
|
return { boardsCreated };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
github: createTRPCRouter({
|
||||||
|
getProjects: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get projects from GitHub",
|
||||||
|
method: "GET",
|
||||||
|
path: "/integrations/github/projects",
|
||||||
|
description: "Retrieves all projects from GitHub",
|
||||||
|
tags: ["Integrations"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(z.void())
|
||||||
|
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
"github",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "GitHub token not found",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = decryptToken(integration.accessToken);
|
||||||
|
|
||||||
|
// GraphQL query to fetch Projects V2 for the user and their organizations
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
viewer {
|
||||||
|
projectsV2(first: 20) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
organizations(first: 10) {
|
||||||
|
nodes {
|
||||||
|
projectsV2(first: 10) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Kan-App",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
`GitHub API Error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
console.error(`GitHub API Response: ${errorText}`);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as GitHubProjectsResponse;
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
console.error("GitHub GraphQL Errors:", result.errors);
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Failed to fetch GitHub projects (GraphQL Error)",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
|
||||||
|
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
|
||||||
|
(org) => org.projectsV2.nodes ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allProjects = [...userProjects, ...orgProjects];
|
||||||
|
|
||||||
|
return allProjects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
name: project.title,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
importProjects: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Import projects from GitHub",
|
||||||
|
method: "POST",
|
||||||
|
path: "/imports/github/projects",
|
||||||
|
description: "Imports projects from GitHub",
|
||||||
|
tags: ["Imports"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectIds: z.array(z.string()),
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ projectsImported: z.number() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
"github",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "GitHub token not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = decryptToken(integration.accessToken);
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
if (!workspace)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Workspace not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||||
|
|
||||||
|
const newImport = await importRepo.create(ctx.db, {
|
||||||
|
source: "github",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newImport) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create import record",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newImportId = newImport.id;
|
||||||
|
let projectsImported = 0;
|
||||||
|
|
||||||
|
for (const projectId of input.projectIds) {
|
||||||
|
// GraphQL query to fetch Project V2 details, status options, area options, and items
|
||||||
|
const query = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on ProjectV2 {
|
||||||
|
title
|
||||||
|
field(name: "Status") {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
areaField: field(name: "Area") {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(first: 100) {
|
||||||
|
nodes {
|
||||||
|
fieldValueByName(name: "Status") {
|
||||||
|
... on ProjectV2ItemFieldSingleSelectValue {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
areaValue: fieldValueByName(name: "Area") {
|
||||||
|
... on ProjectV2ItemFieldSingleSelectValue {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content {
|
||||||
|
... on Issue {
|
||||||
|
title
|
||||||
|
body
|
||||||
|
}
|
||||||
|
... on PullRequest {
|
||||||
|
title
|
||||||
|
body
|
||||||
|
}
|
||||||
|
... on DraftIssue {
|
||||||
|
title
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Kan-App",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables: { id: projectId } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await response.json()) as GitHubGraphQLResponse;
|
||||||
|
if (result.errors || !result.data?.node) continue;
|
||||||
|
|
||||||
|
const projectData = result.data.node;
|
||||||
|
const statusOptions = projectData.field?.options ?? [];
|
||||||
|
const areaOptions = projectData.areaField?.options ?? [];
|
||||||
|
const items = projectData.items?.nodes ?? [];
|
||||||
|
|
||||||
|
const boardPublicId = generateUID();
|
||||||
|
const board = await boardRepo.create(ctx.db, {
|
||||||
|
publicId: boardPublicId,
|
||||||
|
name: projectData.title,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
slug: generateSlug(projectData.title),
|
||||||
|
createdBy: userId,
|
||||||
|
importId: newImportId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!board) continue;
|
||||||
|
|
||||||
|
// Prepare Labels
|
||||||
|
const labelsInsert = areaOptions.map((option) => {
|
||||||
|
let colourCode = "#0d9488"; // Default Teal
|
||||||
|
const ghColor = option.color;
|
||||||
|
|
||||||
|
// Map GitHub colors to Kan colors
|
||||||
|
if (ghColor === "BLUE") colourCode = "#0284c7";
|
||||||
|
else if (ghColor === "GREEN") colourCode = "#65a30d";
|
||||||
|
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
|
||||||
|
else if (ghColor === "ORANGE") colourCode = "#ea580c";
|
||||||
|
else if (ghColor === "RED") colourCode = "#dc2626";
|
||||||
|
else if (ghColor === "PINK") colourCode = "#db2777";
|
||||||
|
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
|
||||||
|
else if (ghColor === "GRAY") colourCode = "#0d9488";
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId: generateUID(),
|
||||||
|
name: option.name,
|
||||||
|
colourCode,
|
||||||
|
createdBy: userId,
|
||||||
|
boardId: board.id,
|
||||||
|
importId: newImportId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdLabels = await labelRepo.bulkCreate(
|
||||||
|
ctx.db,
|
||||||
|
labelsInsert,
|
||||||
|
);
|
||||||
|
const labelMap = new Map<string, number>();
|
||||||
|
|
||||||
|
createdLabels.forEach((label, index) => {
|
||||||
|
const originalName = areaOptions[index]?.name;
|
||||||
|
if (originalName) {
|
||||||
|
labelMap.set(originalName, label.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare Lists
|
||||||
|
const listsInsert: {
|
||||||
|
publicId: string;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
boardId: number;
|
||||||
|
index: number;
|
||||||
|
importId: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (statusOptions.length === 0) {
|
||||||
|
listsInsert.push({
|
||||||
|
publicId: generateUID(),
|
||||||
|
name: "To Do",
|
||||||
|
createdBy: userId,
|
||||||
|
boardId: board.id,
|
||||||
|
index: 0,
|
||||||
|
importId: newImportId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
statusOptions.forEach((option, index) => {
|
||||||
|
listsInsert.push({
|
||||||
|
publicId: generateUID(),
|
||||||
|
name: option.name,
|
||||||
|
createdBy: userId,
|
||||||
|
boardId: board.id,
|
||||||
|
index: index,
|
||||||
|
importId: newImportId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
|
||||||
|
const listIdMap = new Map<string, number>();
|
||||||
|
createdLists.forEach((list, index) => {
|
||||||
|
const originalName = listsInsert[index]?.name;
|
||||||
|
if (originalName) {
|
||||||
|
listIdMap.set(originalName, list.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare Cards
|
||||||
|
const itemsToInsert: {
|
||||||
|
item: NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
|
||||||
|
>[number]
|
||||||
|
>;
|
||||||
|
listId: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const statusName = item.fieldValueByName?.name;
|
||||||
|
const content = item.content ?? {};
|
||||||
|
const title = content.title ?? "Untitled Card";
|
||||||
|
const description = content.body ?? "";
|
||||||
|
|
||||||
|
let listId = statusName ? listIdMap.get(statusName) : undefined;
|
||||||
|
|
||||||
|
// Fallback to first list
|
||||||
|
if (!listId && createdLists.length > 0) {
|
||||||
|
listId = createdLists[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listId) {
|
||||||
|
itemsToInsert.push({
|
||||||
|
item,
|
||||||
|
listId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardsInput = itemsToInsert.map((data, index) => ({
|
||||||
|
publicId: generateUID(),
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
createdBy: userId,
|
||||||
|
listId: data.listId,
|
||||||
|
index: index,
|
||||||
|
importId: newImportId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
|
||||||
|
|
||||||
|
// Create Activities
|
||||||
|
const activities = createdCards.map((card) => ({
|
||||||
|
type: "card.created" as const,
|
||||||
|
cardId: card.id,
|
||||||
|
createdBy: userId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (activities.length > 0) {
|
||||||
|
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link Labels
|
||||||
|
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
|
||||||
|
createdCards.forEach((card, index) => {
|
||||||
|
const originalItem = itemsToInsert[index]?.item;
|
||||||
|
const areaName = originalItem?.areaValue?.name;
|
||||||
|
|
||||||
|
if (areaName) {
|
||||||
|
const labelId = labelMap.get(areaName);
|
||||||
|
if (labelId) {
|
||||||
|
cardLabelRelations.push({
|
||||||
|
cardId: card.id,
|
||||||
|
labelId: labelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cardLabelRelations.length > 0) {
|
||||||
|
await cardRepo.bulkCreateCardLabelRelationships(
|
||||||
|
ctx.db,
|
||||||
|
cardLabelRelations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
projectsImported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectsImported > 0 && newImportId) {
|
||||||
|
await importRepo.update(
|
||||||
|
ctx.db,
|
||||||
|
{ status: "success" },
|
||||||
|
{ importId: newImportId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { projectsImported };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,65 @@ export const apiKeys = {
|
|||||||
trello: process.env.TRELLO_APP_API_KEY,
|
trello: process.env.TRELLO_APP_API_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { encryptToken } from "../utils/encryption";
|
||||||
|
|
||||||
export const integrationRouter = createTRPCRouter({
|
export const integrationRouter = createTRPCRouter({
|
||||||
|
saveGitHubToken: protectedProcedure
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedToken = encryptToken(input.token);
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
|
||||||
|
|
||||||
|
await integrationsRepo.createOrUpdateProvider(ctx.db, {
|
||||||
|
provider: "github",
|
||||||
|
userId: user.id,
|
||||||
|
accessToken: encryptedToken,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const connected = await integrationsRepo.isProviderAvailableForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
"github",
|
||||||
|
);
|
||||||
|
return { connected };
|
||||||
|
}),
|
||||||
|
|
||||||
providers: protectedProcedure
|
providers: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -67,7 +125,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
protect: true,
|
protect: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(z.object({ provider: z.enum(["trello"]) }))
|
.input(z.object({ provider: z.enum(["trello", "github"]) }))
|
||||||
.output(z.object({}))
|
.output(z.object({}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = ctx.user;
|
const user = ctx.user;
|
||||||
|
|||||||
53
packages/api/src/utils/encryption.ts
Normal file
53
packages/api/src/utils/encryption.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
const SECRET_KEY = process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
|
if (!SECRET_KEY) {
|
||||||
|
throw new Error("Encryption key is missing. Set BETTER_AUTH_SECRET.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the key is exactly 32 bytes
|
||||||
|
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
|
||||||
|
|
||||||
|
export const encryptToken = (text: string) => {
|
||||||
|
const iv = crypto.randomBytes(12); // 12 bytes is the recommended IV size for GCM
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
// buffer concat is faster/cleaner for raw binary manipulation
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(text, "utf8"),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Combine IV + AuthTag + EncryptedData into one buffer
|
||||||
|
// This saves space compared to storing them as separate hex strings
|
||||||
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
|
||||||
|
// Return as URL-safe Base64 (ideal for cookies)
|
||||||
|
return combined.toString("base64url");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptToken = (text: string) => {
|
||||||
|
// Convert URL-safe Base64 back to a Buffer
|
||||||
|
const combined = Buffer.from(text, "base64url");
|
||||||
|
|
||||||
|
// Extract the parts based on fixed lengths
|
||||||
|
// IV is 12 bytes (standard for GCM)
|
||||||
|
// AuthTag is 16 bytes (standard for GCM)
|
||||||
|
const iv = combined.subarray(0, 12);
|
||||||
|
const authTag = combined.subarray(12, 28);
|
||||||
|
const encryptedText = combined.subarray(28);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
// If the cookie was tampered with, this will throw an error
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encryptedText),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TYPE "public"."source" ADD VALUE 'github';--> statement-breakpoint
|
||||||
|
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint
|
||||||
2983
packages/db/migrations/meta/20260224105235_snapshot.json
Normal file
2983
packages/db/migrations/meta/20260224105235_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
|||||||
"when": 1772049587901,
|
"when": 1772049587901,
|
||||||
"tag": "20260225195947_AddIsArchivedToBoard",
|
"tag": "20260225195947_AddIsArchivedToBoard",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771930355536,
|
||||||
|
"tag": "20260224105235_AddGitHubIntegrationSupport",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,35 @@ export const getProvidersForUser = async (db: dbClient, userId: string) => {
|
|||||||
return integration;
|
return integration;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createOrUpdateProvider = async (
|
||||||
|
db: dbClient,
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
provider: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string | null;
|
||||||
|
expiresAt: Date;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.insert(integrations)
|
||||||
|
.values({
|
||||||
|
provider: data.provider,
|
||||||
|
userId: data.userId,
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken ?? null,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [integrations.userId, integrations.provider],
|
||||||
|
set: {
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken ?? null,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteProviderForUser = async (
|
export const deleteProviderForUser = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { labels } from "./labels";
|
|||||||
import { lists } from "./lists";
|
import { lists } from "./lists";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
export const importSourceEnum = pgEnum("source", ["trello"]);
|
export const importSourceEnum = pgEnum("source", ["trello", "github"]);
|
||||||
export const importStatusEnum = pgEnum("status", [
|
export const importStatusEnum = pgEnum("status", [
|
||||||
"started",
|
"started",
|
||||||
"success",
|
"success",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
@@ -16,7 +17,7 @@ export const integrations = pgTable(
|
|||||||
userId: uuid("userId")
|
userId: uuid("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
accessToken: varchar("accessToken", { length: 255 }).notNull(),
|
accessToken: text("accessToken").notNull(),
|
||||||
refreshToken: varchar("refreshToken", { length: 255 }),
|
refreshToken: varchar("refreshToken", { length: 255 }),
|
||||||
expiresAt: timestamp("expiresAt").notNull(),
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
createdAt: timestamp("createdAt")
|
createdAt: timestamp("createdAt")
|
||||||
|
|||||||
Reference in New Issue
Block a user