Compare commits
17 Commits
fix/8-self
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfdec1bbf | ||
|
|
f0e3c4c3e2 | ||
|
|
d6b0b5cd80 | ||
|
|
1707557bea | ||
|
|
f6114a56af | ||
|
|
c1d0bb3c93 | ||
|
|
09ae4292ac | ||
|
|
75e89f118f | ||
|
|
dd6cdfc208 | ||
|
|
7758bd77ea | ||
|
|
294ed2ffc2 | ||
|
|
e2786f31b0 | ||
|
|
bfc69996c6 | ||
|
|
96f2835bab | ||
|
|
53426bf155 | ||
|
|
17c2a1b8ec | ||
|
|
d203f834cc |
@@ -9,6 +9,8 @@ SMTP_PASSWORD=
|
|||||||
NEXT_PUBLIC_BASE_URL=
|
NEXT_PUBLIC_BASE_URL=
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
@@ -16,9 +18,11 @@ S3_ACCESS_KEY_ID=
|
|||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
BETTER_AUTH_URL=
|
|
||||||
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=
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ dist/
|
|||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# runtime env
|
||||||
|
__ENV.js
|
||||||
53
README.md
53
README.md
@@ -105,31 +105,34 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- |
|
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | Yes | `http://localhost:3000,http://localhost:3001` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `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` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
"@kan/db": "workspace:^",
|
"@kan/db": "workspace:^",
|
||||||
"@kan/shared": "workspace:^",
|
"@kan/shared": "workspace:^",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/pm": "^2.14.0",
|
||||||
|
"@tiptap/react": "^2.14.0",
|
||||||
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
|
"@tiptap/suggestion": "^2.14.0",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
@@ -41,10 +47,12 @@
|
|||||||
"react-contenteditable": "^3.3.7",
|
"react-contenteditable": "^3.3.7",
|
||||||
"react-dom": "catalog:react18",
|
"react-dom": "catalog:react18",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};
|
|
||||||
@@ -1,27 +1,52 @@
|
|||||||
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
|
import {
|
||||||
|
FaApple,
|
||||||
|
FaDiscord,
|
||||||
|
FaDropbox,
|
||||||
|
FaFacebook,
|
||||||
|
FaGithub,
|
||||||
|
FaGitlab,
|
||||||
|
FaGoogle,
|
||||||
|
FaLinkedin,
|
||||||
|
FaMicrosoft,
|
||||||
|
FaReddit,
|
||||||
|
FaSpotify,
|
||||||
|
FaTiktok,
|
||||||
|
FaTwitch,
|
||||||
|
FaTwitter,
|
||||||
|
FaVk,
|
||||||
|
} from "react-icons/fa";
|
||||||
import { SiRoblox, SiZoom } from "react-icons/si";
|
import { SiRoblox, SiZoom } from "react-icons/si";
|
||||||
import { TbBrandKick } from "react-icons/tb";
|
import { TbBrandKick } from "react-icons/tb";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SocialProvider } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthProps {
|
interface AuthProps {
|
||||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||||
|
isSignUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailSchema = z.object({ email: z.string().email() });
|
const EmailSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const availableSocialProviders = {
|
const availableSocialProviders = {
|
||||||
google: {
|
google: {
|
||||||
@@ -114,19 +139,22 @@ const availableSocialProviders = {
|
|||||||
name: "Zoom",
|
name: "Zoom",
|
||||||
icon: SiZoom,
|
icon: SiZoom,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
null | SocialProvider
|
useState<null | SocialProvider>(null);
|
||||||
>(null);
|
const isCredentialsEnabled =
|
||||||
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
watch,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(EmailSchema),
|
resolver: zodResolver(EmailSchema),
|
||||||
});
|
});
|
||||||
@@ -136,27 +164,67 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
queryFn: () => authClient.getSocialProviders(),
|
queryFn: () => authClient.getSocialProviders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLoginWithEmail = async (email: string) => {
|
const handleLoginWithEmail = async (
|
||||||
|
email: string,
|
||||||
|
password?: string,
|
||||||
|
name?: string,
|
||||||
|
) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.magicLink({
|
if (password) {
|
||||||
email,
|
if (isSignUp && name) {
|
||||||
callbackURL: "/boards",
|
await authClient.signUp.email(
|
||||||
});
|
{
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
showPopup({
|
||||||
|
header: "Success",
|
||||||
|
message: "You have been signed up successfully.",
|
||||||
|
icon: "success",
|
||||||
|
}),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await authClient.signIn.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
showPopup({
|
||||||
|
header: "Success",
|
||||||
|
message: "You have been logged in successfully.",
|
||||||
|
icon: "success",
|
||||||
|
}),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await authClient.signIn.magicLink(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoginWithEmailPending(false);
|
setIsLoginWithEmailPending(false);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setLoginError(
|
|
||||||
"Something went wrong, please try again later or contact customer support.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setIsMagicLinkSent(true, email);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithProvider = async (
|
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||||
provider: SocialProvider) => {
|
|
||||||
setIsLoginWithProviderPending(provider);
|
setIsLoginWithProviderPending(provider);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.social({
|
const { error } = await authClient.signIn.social({
|
||||||
@@ -174,9 +242,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
await handleLoginWithEmail(values.email);
|
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const password = watch("password");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{socialProviders?.length !== 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
@@ -186,16 +256,17 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||||
isLoading={isLoginWithProviderPending === key}
|
isLoading={isLoginWithProviderPending === key}
|
||||||
iconLeft={<provider.icon />}
|
iconLeft={<provider.icon />}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Continue with {provider.name}
|
Continue with {provider.name}
|
||||||
</Button>
|
</Button>
|
||||||
)})}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@@ -208,26 +279,60 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
{...register("email", { required: true })}
|
{isSignUp && isCredentialsEnabled && (
|
||||||
placeholder="Enter your email address"
|
<div>
|
||||||
/>
|
<Input
|
||||||
{errors.email && (
|
{...register("name", { required: true })}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
placeholder="Enter your name"
|
||||||
Please enter a valid email address
|
/>
|
||||||
</p>
|
{errors.name && (
|
||||||
)}
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{loginError && (
|
Please enter a valid name
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-[1.5rem]">
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
{...register("email", { required: true })}
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Please enter a valid email address
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCredentialsEnabled && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...register("password", { required: true })}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Please enter a valid password
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Continue with email
|
{isSignUp ? "Sign up with " : "Continue with "}
|
||||||
|
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||||
|
? "email"
|
||||||
|
: "magic link"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
419
apps/web/src/components/Editor.tsx
Normal file
419
apps/web/src/components/Editor.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
|
import type {
|
||||||
|
SuggestionKeyDownProps,
|
||||||
|
SuggestionOptions,
|
||||||
|
} from "@tiptap/suggestion";
|
||||||
|
import type { Instance as TippyInstance } from "tippy.js";
|
||||||
|
import { Button } from "@headlessui/react";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import {
|
||||||
|
BubbleMenu,
|
||||||
|
EditorContent,
|
||||||
|
Extension,
|
||||||
|
ReactRenderer,
|
||||||
|
useEditor,
|
||||||
|
} from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Suggestion from "@tiptap/suggestion";
|
||||||
|
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
HiH1,
|
||||||
|
HiH2,
|
||||||
|
HiH3,
|
||||||
|
HiOutlineBold,
|
||||||
|
HiOutlineChatBubbleLeftEllipsis,
|
||||||
|
HiOutlineCodeBracket,
|
||||||
|
HiOutlineCodeBracketSquare,
|
||||||
|
HiOutlineItalic,
|
||||||
|
HiOutlineListBullet,
|
||||||
|
HiOutlineNumberedList,
|
||||||
|
HiOutlineStrikethrough,
|
||||||
|
} from "react-icons/hi2";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
slashSuggestion: {
|
||||||
|
setSlashSuggestion: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandItem {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandsOptions {
|
||||||
|
suggestion?: Partial<SuggestionOptions>;
|
||||||
|
commandItems?: SlashCommandItem[];
|
||||||
|
options?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSlashCommandItems(items: SlashCommandItem[], query: string) {
|
||||||
|
return items.filter((item) =>
|
||||||
|
item.title.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSuggestionsProps {
|
||||||
|
editor: TiptapEditor;
|
||||||
|
clientRect: () => DOMRect;
|
||||||
|
items: SlashCommandItem[];
|
||||||
|
command: (item: SlashCommandItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandsList = forwardRef<
|
||||||
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
|
{
|
||||||
|
items: SlashCommandItem[];
|
||||||
|
command: (item: SlashCommandItem) => void;
|
||||||
|
}
|
||||||
|
>(({ items, command }, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
const item = items[selectedIndex];
|
||||||
|
if (item) {
|
||||||
|
command(item);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||||
|
<div className="max-h-[350px] overflow-y-auto p-1">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
onClick={() => command(item)}
|
||||||
|
className={twMerge(
|
||||||
|
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||||
|
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-dark-700 dark:text-dark-800">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CommandsList.displayName = "CommandsList";
|
||||||
|
|
||||||
|
const RenderSuggestions = () => {
|
||||||
|
let reactRenderer: ReactRenderer;
|
||||||
|
let popup: TippyInstance[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: RenderSuggestionsProps) => {
|
||||||
|
reactRenderer = new ReactRenderer(CommandsList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) return;
|
||||||
|
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: reactRenderer.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate(props: RenderSuggestionsProps) {
|
||||||
|
reactRenderer.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect) return;
|
||||||
|
|
||||||
|
popup[0]?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup[0]?.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
reactRenderer.ref as {
|
||||||
|
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
|
||||||
|
}
|
||||||
|
).onKeyDown?.(props) ?? false
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
reactRenderer.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||||
|
name: "slash-commands",
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: "/",
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
items: ({ query }: { query: string }) => {
|
||||||
|
return filterSlashCommandItems(
|
||||||
|
this.parent().commandItems ?? [],
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
let component: ReturnType<typeof RenderSuggestions>;
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
component = RenderSuggestions();
|
||||||
|
component.onStart(props);
|
||||||
|
},
|
||||||
|
onUpdate(props: any) {
|
||||||
|
component.onUpdate(props);
|
||||||
|
},
|
||||||
|
onKeyDown(props: any) {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return component.onKeyDown(props) ?? false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
component.onExit();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commandItems: [] as SlashCommandItem[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
render: RenderSuggestions,
|
||||||
|
} as SuggestionOptions),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SlashNodeAttrs {
|
||||||
|
id: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandItems: SlashCommandItem[] = [
|
||||||
|
{
|
||||||
|
title: "Heading 1",
|
||||||
|
icon: <HiH1 />,
|
||||||
|
command: ({ editor }) =>
|
||||||
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 2",
|
||||||
|
icon: <HiH2 />,
|
||||||
|
command: ({ editor }) =>
|
||||||
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 3",
|
||||||
|
icon: <HiH3 />,
|
||||||
|
command: ({ editor }) =>
|
||||||
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bullet List",
|
||||||
|
icon: <HiOutlineListBullet />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ordered List",
|
||||||
|
icon: <HiOutlineNumberedList />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Blockquote",
|
||||||
|
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code Block",
|
||||||
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
readOnly = false,
|
||||||
|
}: {
|
||||||
|
content: string | null;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: readOnly
|
||||||
|
? ""
|
||||||
|
: "Add description... (type '/' to open commands)",
|
||||||
|
}),
|
||||||
|
SlashCommands.configure({
|
||||||
|
commandItems: CommandItems,
|
||||||
|
suggestion: {
|
||||||
|
items: ({ query }: { query: string }) =>
|
||||||
|
filterSlashCommandItems(CommandItems, query),
|
||||||
|
startOfLine: true,
|
||||||
|
char: "/",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
|
onBlur: ({ event }) => {
|
||||||
|
if (
|
||||||
|
document
|
||||||
|
.querySelector(".tippy-box")
|
||||||
|
?.contains(event.relatedTarget as Node)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// Only trigger onBlur if the click was outside both the editor and menu
|
||||||
|
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
|
||||||
|
onBlur?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editable: !readOnly,
|
||||||
|
injectCSS: false,
|
||||||
|
},
|
||||||
|
[content],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<style jsx global>{`
|
||||||
|
.tiptap p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tiptap p {
|
||||||
|
margin: 0 0 1rem 0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorBubbleMenu({ editor }: { editor: TiptapEditor | null }) {
|
||||||
|
const isMac = navigator.platform.includes("Mac");
|
||||||
|
|
||||||
|
const bubbleMenuItems = [
|
||||||
|
{
|
||||||
|
title: "Bold",
|
||||||
|
icon: <HiOutlineBold />,
|
||||||
|
keys: ["meta", "b"],
|
||||||
|
onClick: () => editor?.chain().focus().toggleBold().run(),
|
||||||
|
active: editor?.isActive("bold"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Italic",
|
||||||
|
icon: <HiOutlineItalic />,
|
||||||
|
keys: ["meta", "i"],
|
||||||
|
onClick: () => editor?.chain().focus().toggleItalic().run(),
|
||||||
|
active: editor?.isActive("italic"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Strikethrough",
|
||||||
|
icon: <HiOutlineStrikethrough />,
|
||||||
|
keys: ["meta", "shift", "s"],
|
||||||
|
onClick: () => editor?.chain().focus().toggleStrike().run(),
|
||||||
|
active: editor?.isActive("strike"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code",
|
||||||
|
icon: <HiOutlineCodeBracket />,
|
||||||
|
keys: ["meta", "e"],
|
||||||
|
onClick: () => editor?.chain().focus().toggleCode().run(),
|
||||||
|
active: editor?.isActive("code"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<BubbleMenu editor={editor}>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-light-600 bg-light-50 p-1 dark:border-dark-600 dark:bg-dark-50">
|
||||||
|
{bubbleMenuItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.title}
|
||||||
|
className={twMerge(
|
||||||
|
"rounded p-1 text-light-900 focus:ring-2 focus:ring-light-600 dark:text-dark-900 dark:focus:ring-dark-600",
|
||||||
|
item.active && "bg-light-100 dark:bg-dark-400",
|
||||||
|
)}
|
||||||
|
title={`${item.title} [${item.keys.join(" + ").replace("meta", isMac ? "⌘" : "ctrl")}]`}
|
||||||
|
onClick={item.onClick}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
item.onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
||||||
prefix && "rounded-l-none",
|
prefix && "rounded-l-none",
|
||||||
|
type === "password" && "pr-8",
|
||||||
className && className,
|
className && className,
|
||||||
)}
|
)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -75,7 +76,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{type === "password" && (
|
{type === "password" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_URL: z.string(),
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
.string()
|
||||||
|
.refine((s) =>
|
||||||
|
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||||
|
)
|
||||||
|
.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(),
|
||||||
@@ -60,7 +66,7 @@ export const env = createEnv({
|
|||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
EMAIL_FROM: z.string(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,10 +76,18 @@ export const env = createEnv({
|
|||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
@@ -86,6 +100,8 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||||
|
|||||||
@@ -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,14 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
@@ -48,12 +51,14 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
{!isSignUpDisabled && (
|
||||||
Don't have an account?{" "}
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
<span className="underline">
|
Don't have an account?{" "}
|
||||||
<Link href="/signup">Sign up</Link>
|
<span className="underline">
|
||||||
</span>
|
<Link href="/signup">Sign up</Link>
|
||||||
</p>
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
// import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
// const router = useRouter();
|
const router = useRouter();
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
@@ -18,15 +19,18 @@ export default function SignupPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const authCookieExists = document.cookie
|
const { data } = authClient.useSession();
|
||||||
// .split("; ")
|
|
||||||
// .some((cookie) => cookie.includes("auth-token"));
|
|
||||||
|
|
||||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
if (data?.user.id) router.push("/boards");
|
||||||
// enabled: authCookieExists ? true : false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (data?.id) router.push("/boards");
|
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-light-100 dark:bg-dark-50 h-screen items-center justify-center">
|
||||||
|
<p className="mb-2 text-light-1000 dark:text-dark-1000 font-semibold text-sm">Sign up is disabled</p>
|
||||||
|
<Link href="/login" className="text-light-1000 dark:text-dark-1000 font-semibold text-sm rounded-md border border-light-900 dark:border-dark-900 px-4 py-2">Login</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,7 +55,7 @@ export default function SignupPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
@@ -115,9 +116,9 @@ export function NewCardForm({
|
|||||||
utils.board.byId.setData(queryParams, context?.previousState);
|
utils.board.byId.setData(queryParams, context?.previousState);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: "Unable to create card",
|
header: "Unable to create card",
|
||||||
message: error.data?.zodError?.fieldErrors.title?.[0] ?
|
message: error.data?.zodError?.fieldErrors.title?.[0]
|
||||||
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
|
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
|
||||||
"Please try again later, or contact customer support.",
|
: "Please try again later, or contact customer support.",
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -253,18 +254,12 @@ export function NewCardForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Input
|
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
|
||||||
placeholder="Add description..."
|
<Editor
|
||||||
onChange={(e) => setValue("description", e.target.value)}
|
content=""
|
||||||
value={watch("description")}
|
onChange={(value) => setValue("description", value)}
|
||||||
contentEditable
|
/>
|
||||||
onKeyDown={async (e) => {
|
</div>
|
||||||
if (e.key === "Enter" && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex space-x-1">
|
<div className="mt-2 flex space-x-1">
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
@@ -389,7 +384,12 @@ export function NewCardForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={title.length === 0 || createCard.isPending}
|
||||||
|
>
|
||||||
|
Create card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const VisibilityButton = ({
|
|||||||
}: {
|
}: {
|
||||||
visibility: "public" | "private";
|
visibility: "public" | "private";
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
|
boardSlug: string;
|
||||||
queryParams: QueryParams;
|
queryParams: QueryParams;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -38,7 +40,11 @@ const VisibilityButton = ({
|
|||||||
|
|
||||||
const updateBoardVisibility = api.board.update.useMutation({
|
const updateBoardVisibility = api.board.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setStateVisibility(isPublic ? "private" : "public");
|
showPopup({
|
||||||
|
header: "Board visibility updated",
|
||||||
|
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -52,23 +58,39 @@ const VisibilityButton = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateBoardVisibility = () => {
|
|
||||||
updateBoardVisibility.mutate({
|
|
||||||
visibility: isPublic ? "private" : "public",
|
|
||||||
boardPublicId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="relative">
|
||||||
variant="secondary"
|
<CheckboxDropdown
|
||||||
onClick={handleUpdateBoardVisibility}
|
items={[
|
||||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
{
|
||||||
isLoading={updateBoardVisibility.isPending}
|
key: "public",
|
||||||
disabled={isLoading || !isAdmin}
|
value: "Public",
|
||||||
>
|
selected: isPublic,
|
||||||
{isPublic ? "Public" : "Private"}
|
},
|
||||||
</Button>
|
{
|
||||||
|
key: "private",
|
||||||
|
value: "Private",
|
||||||
|
selected: !isPublic,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
handleSelect={(_g, i) => {
|
||||||
|
setStateVisibility(isPublic ? "private" : "public");
|
||||||
|
updateBoardVisibility.mutate({
|
||||||
|
visibility: i.key as "public" | "private",
|
||||||
|
boardPublicId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
menuSpacing="md"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||||
|
disabled={isLoading || !isAdmin}
|
||||||
|
>
|
||||||
|
Visibility
|
||||||
|
</Button>
|
||||||
|
</CheckboxDropdown>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -260,13 +260,16 @@ export default function BoardPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!boardData && !isLoading && (
|
||||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">Board not found</p>
|
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
|
Board not found
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
visibility={boardData?.visibility ?? "private"}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
|
boardSlug={boardData?.slug ?? ""}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
isAdmin={workspace.role === "admin"}
|
isAdmin={workspace.role === "admin"}
|
||||||
@@ -354,7 +357,7 @@ export default function BoardPage() {
|
|||||||
<div
|
<div
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
|
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
|
||||||
>
|
>
|
||||||
{list.cards.map((card, index) => (
|
{list.cards.map((card, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
@@ -374,12 +377,13 @@ export default function BoardPage() {
|
|||||||
}}
|
}}
|
||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
href={`/cards/${card.publicId}`}
|
href={`/cards/${card.publicId}`}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
"PLACEHOLDER",
|
card.publicId.startsWith(
|
||||||
)
|
"PLACEHOLDER",
|
||||||
? "pointer-events-none"
|
)
|
||||||
: ""
|
? "pointer-events-none"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ const Comment = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<p className="mt-2 text-sm">{comment}</p>
|
<ContentEditable
|
||||||
|
html={comment ?? ""}
|
||||||
|
disabled={true}
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import ContentEditable from "react-contenteditable";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||||
|
|
||||||
@@ -24,6 +23,7 @@ import LabelSelector from "./components/LabelSelector";
|
|||||||
import ListSelector from "./components/ListSelector";
|
import ListSelector from "./components/ListSelector";
|
||||||
import MemberSelector from "./components/MemberSelector";
|
import MemberSelector from "./components/MemberSelector";
|
||||||
import NewCommentForm from "./components/NewCommentForm";
|
import NewCommentForm from "./components/NewCommentForm";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -137,6 +137,8 @@ export default function CardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const description = watch("description");
|
||||||
|
|
||||||
if (!cardId) return <></>;
|
if (!cardId) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -199,13 +201,10 @@ export default function CardPage() {
|
|||||||
className="w-full space-y-6"
|
className="w-full space-y-6"
|
||||||
>
|
>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ContentEditable
|
<Editor
|
||||||
placeholder="Add description..."
|
content={card.description}
|
||||||
html={watch("description")}
|
onChange={(e) => setValue("description", e)}
|
||||||
disabled={false}
|
onBlur={() => handleSubmit(onSubmit)()}
|
||||||
onChange={(e) => setValue("description", e.target.value)}
|
|
||||||
onBlur={handleSubmit(onSubmit)}
|
|
||||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -16,6 +18,7 @@ export function CardModal({
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal, isOpen } = useModal();
|
const { closeModal, isOpen } = useModal();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const { data, isLoading } = api.card.byId.useQuery(
|
const { data, isLoading } = api.card.byId.useQuery(
|
||||||
{
|
{
|
||||||
@@ -90,9 +93,7 @@ export function CardModal({
|
|||||||
{data?.description && (
|
{data?.description && (
|
||||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6">
|
<Editor content={data.description} readOnly />
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function PublicBoardView() {
|
|||||||
{list.name}
|
{list.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600">
|
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||||
{list.cards.map((card) => (
|
{list.cards.map((card) => (
|
||||||
<Link
|
<Link
|
||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
export function DeleteAccountConfirmation() {
|
||||||
|
const { closeModal } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||||
|
|
||||||
|
const deleteAccountMutation = useMutation({
|
||||||
|
mutationFn: () => authClient.deleteUser(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
closeModal();
|
||||||
|
showPopup({
|
||||||
|
header: "Account deleted",
|
||||||
|
message: "Your account has been deleted.",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.invalidate();
|
||||||
|
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
closeModal();
|
||||||
|
showPopup({
|
||||||
|
header: "Error deleting account",
|
||||||
|
message: "Please try again later, or contact customer support.",
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
deleteAccountMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex w-full flex-col justify-between pb-4">
|
||||||
|
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
|
{`Are you sure you want to delete your account?`}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||||
|
Keep in mind that this action is irreversible.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
This will result in the permanent deletion of all data associated with
|
||||||
|
your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex h-6 items-center">
|
||||||
|
<input
|
||||||
|
id="acknowledgment"
|
||||||
|
name="acknowledgment"
|
||||||
|
type="checkbox"
|
||||||
|
aria-describedby="acknowledgment-description"
|
||||||
|
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
|
||||||
|
checked={isAcknowledgmentChecked}
|
||||||
|
onChange={() =>
|
||||||
|
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm leading-6">
|
||||||
|
<p
|
||||||
|
id="comments-description"
|
||||||
|
className="text-light-900 dark:text-dark-1000"
|
||||||
|
>
|
||||||
|
I acknowledge that all of my account data will be permanently
|
||||||
|
deleted and want to proceed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
|
<Button variant="secondary" onClick={() => closeModal()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
disabled={!isAcknowledgmentChecked}
|
||||||
|
isLoading={deleteAccountMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
</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,11 +7,13 @@ 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";
|
||||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||||
|
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||||
@@ -21,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 () => {
|
||||||
@@ -114,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
|
||||||
@@ -127,20 +217,40 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-light-300 dark:border-dark-300">
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
<h2 className="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">
|
||||||
Delete workspace
|
Delete workspace
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
Once you delete your workspace, there is no going back. Please
|
Once you delete your workspace, there is no going back. This
|
||||||
be certain.
|
action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div className="mt-4">
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
variant="secondary"
|
||||||
>
|
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||||
Delete workspace
|
>
|
||||||
</Button>
|
Delete workspace
|
||||||
|
</Button>
|
||||||
|
</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">
|
||||||
|
Delete account
|
||||||
|
</h2>
|
||||||
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
Once you delete your account, there is no going back. This
|
||||||
|
action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => openModal("DELETE_ACCOUNT")}
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,6 +262,9 @@ export default function SettingsPage() {
|
|||||||
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
||||||
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
||||||
)}
|
)}
|
||||||
|
{modalContentType === "DELETE_ACCOUNT" && (
|
||||||
|
<DeleteAccountConfirmation />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import baseConfig from "@kan/tailwind-config/web";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [...baseConfig.content],
|
content: [...baseConfig.content],
|
||||||
|
plugins: [require("@tailwindcss/typography")],
|
||||||
presets: [baseConfig],
|
presets: [baseConfig],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.7"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
@@ -20,11 +18,12 @@ services:
|
|||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
|
|
||||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
- 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}
|
||||||
@@ -72,6 +71,8 @@ services:
|
|||||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||||
|
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
import { apiKey } from "better-auth/plugins";
|
import { apiKey } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
|
import { socialProviderList } from "better-auth/social-providers";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
@@ -12,7 +13,6 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
|||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { socialProviderList } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
export const configuredProviders = socialProviderList.reduce<
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
Record<
|
Record<
|
||||||
@@ -83,7 +83,8 @@ export const socialProvidersPlugin = () => ({
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
|
async (ctx) =>
|
||||||
|
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -99,10 +100,13 @@ async function downloadImage(url: string): Promise<Buffer> {
|
|||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: process.env.BETTER_AUTH_SECRET!,
|
||||||
baseURL: process.env.BETTER_AUTH_BASE_URL!,
|
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||||
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
|
? [
|
||||||
: [],
|
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
|
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||||
|
]
|
||||||
|
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
@@ -110,8 +114,22 @@ export const initAuth = (db: dbClient) => {
|
|||||||
user: schema.users,
|
user: schema.users,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||||
|
disableSignUp:
|
||||||
|
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
||||||
|
sendResetPassword: async (data) => {
|
||||||
|
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||||
|
resetPasswordUrl: data.url,
|
||||||
|
resetPasswordToken: data.token,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
socialProviders: configuredProviders,
|
socialProviders: configuredProviders,
|
||||||
user: {
|
user: {
|
||||||
|
deleteUser: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
stripeCustomerId: {
|
stripeCustomerId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -148,8 +166,17 @@ export const initAuth = (db: dbClient) => {
|
|||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
async after(user, _context) {
|
before() {
|
||||||
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
|
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
async after(user) {
|
||||||
|
if (
|
||||||
|
user.image &&
|
||||||
|
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
region: env("S3_REGION") ?? "",
|
region: env("S3_REGION") ?? "",
|
||||||
@@ -162,18 +189,21 @@ export const initAuth = (db: dbClient) => {
|
|||||||
|
|
||||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
|
const fileExtension =
|
||||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
|
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||||
|
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||||
|
|
||||||
const imageBuffer = await downloadImage(user.image);
|
const imageBuffer = await downloadImage(user.image);
|
||||||
|
|
||||||
await client.send(new PutObjectCommand({
|
await client.send(
|
||||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
Body: imageBuffer,
|
Key: key,
|
||||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
|
Body: imageBuffer,
|
||||||
ACL: 'public-read',
|
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||||
}));
|
ACL: "public-read",
|
||||||
|
}),
|
||||||
|
);
|
||||||
await userRepo.update(db, user.id, {
|
await userRepo.update(db, user.id, {
|
||||||
image: key,
|
image: key,
|
||||||
});
|
});
|
||||||
@@ -181,9 +211,9 @@ export const initAuth = (db: dbClient) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
after: createAuthMiddleware(async (ctx) => {
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
|
|||||||
@@ -76,4 +76,4 @@ DO $$ BEGIN
|
|||||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN duplicate_object THEN null;
|
WHEN duplicate_object THEN null;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -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 $$;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
ALTER TABLE "board" DROP CONSTRAINT "board_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "board" DROP CONSTRAINT "board_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card" DROP CONSTRAINT "card_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card" DROP CONSTRAINT "card_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "feedback" DROP CONSTRAINT "feedback_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "import" DROP CONSTRAINT "import_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "label" DROP CONSTRAINT "label_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "label" DROP CONSTRAINT "label_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "list" DROP CONSTRAINT "list_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "list" DROP CONSTRAINT "list_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_userId_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_createdBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_deletedBy_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "board" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_activity" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_comments" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "feedback" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "import" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "label" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "list" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "board" ADD CONSTRAINT "board_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "board" ADD CONSTRAINT "board_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card" ADD CONSTRAINT "card_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card" ADD CONSTRAINT "card_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "import" ADD CONSTRAINT "import_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "label" ADD CONSTRAINT "label_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "label" ADD CONSTRAINT "label_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "list" ADD CONSTRAINT "list_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "list" ADD CONSTRAINT "list_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -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 $$;
|
||||||
1941
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
1941
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1871
packages/db/migrations/meta/20250608175448_snapshot.json
Normal file
1871
packages/db/migrations/meta/20250608175448_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2087
packages/db/migrations/meta/20250610200449_snapshot.json
Normal file
2087
packages/db/migrations/meta/20250610200449_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,27 @@
|
|||||||
"when": 1749118315675,
|
"when": 1749118315675,
|
||||||
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749405288761,
|
||||||
|
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749377372062,
|
||||||
|
"tag": "20250608100932_AddIntegrationsTable",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749585889984,
|
||||||
|
"tag": "20250610200449_ReaddIntegrationTable",
|
||||||
|
"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)),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -81,5 +81,6 @@ export const apiKeyRelations = relations(apikey, ({ one }) => ({
|
|||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [apikey.userId],
|
fields: [apikey.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "apiKeyUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ export const boards = pgTable(
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 255 }).notNull(),
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
importId: bigint("importId", { mode: "number" }).references(
|
importId: bigint("importId", { mode: "number" }).references(
|
||||||
() => imports.id,
|
() => imports.id,
|
||||||
),
|
),
|
||||||
@@ -60,19 +62,23 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [boards.createdBy],
|
fields: [boards.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "boardCreatedByUser",
|
||||||
}),
|
}),
|
||||||
lists: many(lists),
|
lists: many(lists),
|
||||||
labels: many(labels),
|
labels: many(labels),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [boards.deletedBy],
|
fields: [boards.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "boardDeletedByUser",
|
||||||
}),
|
}),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [boards.importId],
|
fields: [boards.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "boardImport",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [boards.workspaceId],
|
fields: [boards.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "boardWorkspace",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -44,13 +44,15 @@ export const cards = pgTable("card", {
|
|||||||
title: varchar("title", { length: 255 }).notNull(),
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
index: integer("index").notNull(),
|
index: integer("index").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
listId: bigint("listId", { mode: "number" })
|
listId: bigint("listId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => lists.id, { onDelete: "cascade" }),
|
.references(() => lists.id, { onDelete: "cascade" }),
|
||||||
@@ -61,20 +63,24 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [cards.createdBy],
|
fields: [cards.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
list: one(lists, {
|
list: one(lists, {
|
||||||
fields: [cards.listId],
|
fields: [cards.listId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardsList",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [cards.deletedBy],
|
fields: [cards.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
labels: many(cardsToLabels),
|
labels: many(cardsToLabels),
|
||||||
members: many(cardToWorkspaceMembers),
|
members: many(cardToWorkspaceMembers),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [cards.importId],
|
fields: [cards.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "cardsImport",
|
||||||
}),
|
}),
|
||||||
comments: many(comments),
|
comments: many(comments),
|
||||||
activities: many(cardActivities),
|
activities: many(cardActivities),
|
||||||
@@ -90,23 +96,29 @@ export const cardActivities = pgTable("card_activity", {
|
|||||||
fromIndex: integer("fromIndex"),
|
fromIndex: integer("fromIndex"),
|
||||||
toIndex: integer("toIndex"),
|
toIndex: integer("toIndex"),
|
||||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||||
() => lists.id, { onDelete: "cascade" },
|
() => lists.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
|
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, {
|
||||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }),
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
workspaceMemberId: bigint("workspaceMemberId", {
|
workspaceMemberId: bigint("workspaceMemberId", {
|
||||||
mode: "number",
|
mode: "number",
|
||||||
}).references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
|
||||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||||
toTitle: varchar("toTitle", { length: 255 }),
|
toTitle: varchar("toTitle", { length: 255 }),
|
||||||
fromDescription: text("fromDescription"),
|
fromDescription: text("fromDescription"),
|
||||||
toDescription: text("toDescription"),
|
toDescription: text("toDescription"),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
commentId: bigint("commentId", { mode: "number" }).references(
|
commentId: bigint("commentId", { mode: "number" }).references(
|
||||||
() => comments.id, { onDelete: "cascade" },
|
() => comments.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
fromComment: text("fromComment"),
|
fromComment: text("fromComment"),
|
||||||
toComment: text("toComment"),
|
toComment: text("toComment"),
|
||||||
@@ -116,34 +128,42 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardActivities.cardId],
|
fields: [cardActivities.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardActivitiesCard",
|
||||||
}),
|
}),
|
||||||
fromList: one(lists, {
|
fromList: one(lists, {
|
||||||
fields: [cardActivities.fromListId],
|
fields: [cardActivities.fromListId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardActivitiesFromList",
|
||||||
}),
|
}),
|
||||||
toList: one(lists, {
|
toList: one(lists, {
|
||||||
fields: [cardActivities.toListId],
|
fields: [cardActivities.toListId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardActivitiesToList",
|
||||||
}),
|
}),
|
||||||
label: one(labels, {
|
label: one(labels, {
|
||||||
fields: [cardActivities.labelId],
|
fields: [cardActivities.labelId],
|
||||||
references: [labels.id],
|
references: [labels.id],
|
||||||
|
relationName: "cardActivitiesLabel",
|
||||||
}),
|
}),
|
||||||
workspaceMember: one(workspaceMembers, {
|
workspaceMember: one(workspaceMembers, {
|
||||||
fields: [cardActivities.workspaceMemberId],
|
fields: [cardActivities.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardActivitiesWorkspaceMember",
|
||||||
}),
|
}),
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [cardActivities.createdBy],
|
fields: [cardActivities.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardActivitiesUser",
|
||||||
}),
|
}),
|
||||||
member: one(workspaceMembers, {
|
member: one(workspaceMembers, {
|
||||||
fields: [cardActivities.workspaceMemberId],
|
fields: [cardActivities.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardActivitiesMember",
|
||||||
}),
|
}),
|
||||||
comment: one(comments, {
|
comment: one(comments, {
|
||||||
fields: [cardActivities.commentId],
|
fields: [cardActivities.commentId],
|
||||||
references: [comments.id],
|
references: [comments.id],
|
||||||
|
relationName: "cardActivitiesComment",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -164,10 +184,12 @@ export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardsToLabels.cardId],
|
fields: [cardsToLabels.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardToLabelsCard",
|
||||||
}),
|
}),
|
||||||
label: one(labels, {
|
label: one(labels, {
|
||||||
fields: [cardsToLabels.labelId],
|
fields: [cardsToLabels.labelId],
|
||||||
references: [labels.id],
|
references: [labels.id],
|
||||||
|
relationName: "cardToLabelsLabel",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -190,10 +212,12 @@ export const cardToWorkspaceMembersRelations = relations(
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardToWorkspaceMembers.cardId],
|
fields: [cardToWorkspaceMembers.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardToWorkspaceMembersCard",
|
||||||
}),
|
}),
|
||||||
member: one(workspaceMembers, {
|
member: one(workspaceMembers, {
|
||||||
fields: [cardToWorkspaceMembers.workspaceMemberId],
|
fields: [cardToWorkspaceMembers.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardToWorkspaceMembersMember",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -205,26 +229,31 @@ export const comments = pgTable("card_comments", {
|
|||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id, { onDelete: "cascade" }),
|
.references(() => cards.id, { onDelete: "cascade" }),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "cascade" }),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const commentsRelations = relations(comments, ({ one }) => ({
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [comments.cardId],
|
fields: [comments.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "commentsCard",
|
||||||
}),
|
}),
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [comments.createdBy],
|
fields: [comments.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "commentsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [comments.deletedBy],
|
fields: [comments.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "commentsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { users } from "./users";
|
|||||||
export const feedback = pgTable("feedback", {
|
export const feedback = pgTable("feedback", {
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
feedback: text("feedback").notNull(),
|
feedback: text("feedback").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
@@ -26,5 +26,6 @@ export const feedbackRelations = relations(feedback, ({ one }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [feedback.createdBy],
|
fields: [feedback.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "feedbackCreatedByUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const imports = pgTable("import", {
|
|||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
source: importSourceEnum("source").notNull(),
|
source: importSourceEnum("source").notNull(),
|
||||||
status: importStatusEnum("status").notNull(),
|
status: importStatusEnum("status").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const importsRelations = relations(imports, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [imports.createdBy],
|
fields: [imports.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "importsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
cards: many(cards),
|
cards: many(cards),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -18,9 +18,9 @@ export const labels = pgTable("label", {
|
|||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
colourCode: varchar("colourCode", { length: 12 }),
|
colourCode: varchar("colourCode", { length: 12 }),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
boardId: bigint("boardId", { mode: "number" })
|
boardId: bigint("boardId", { mode: "number" })
|
||||||
@@ -28,17 +28,21 @@ export const labels = pgTable("label", {
|
|||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [labels.createdBy],
|
fields: [labels.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "labelsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [labels.deletedBy],
|
fields: [labels.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "labelsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [labels.boardId],
|
fields: [labels.boardId],
|
||||||
@@ -48,5 +52,6 @@ export const labelsRelations = relations(labels, ({ one, many }) => ({
|
|||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [labels.importId],
|
fields: [labels.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "labelsImport",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ export const lists = pgTable("list", {
|
|||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
index: integer("index").notNull(),
|
index: integer("index").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
boardId: bigint("boardId", { mode: "number" })
|
boardId: bigint("boardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
@@ -36,18 +38,22 @@ export const listsRelations = relations(lists, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [lists.createdBy],
|
fields: [lists.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "listsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [lists.boardId],
|
fields: [lists.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
|
relationName: "listsBoard",
|
||||||
}),
|
}),
|
||||||
cards: many(cards),
|
cards: many(cards),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [lists.deletedBy],
|
fields: [lists.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "listsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [lists.importId],
|
fields: [lists.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "listsImport",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -29,12 +30,33 @@ export const users = pgTable("user", {
|
|||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
boards: many(boards),
|
deletedBoards: many(boards, {
|
||||||
cards: many(cards),
|
relationName: "boardDeletedByUser",
|
||||||
|
}),
|
||||||
|
boards: many(boards, {
|
||||||
|
relationName: "boardCreatedByUser",
|
||||||
|
}),
|
||||||
|
deletedCards: many(cards, {
|
||||||
|
relationName: "cardsDeletedByUser",
|
||||||
|
}),
|
||||||
|
cards: many(cards, {
|
||||||
|
relationName: "cardsCreatedByUser",
|
||||||
|
}),
|
||||||
imports: many(imports),
|
imports: many(imports),
|
||||||
lists: many(lists),
|
deletedLists: many(lists, {
|
||||||
workspaces: many(workspaces),
|
relationName: "listsDeletedByUser",
|
||||||
|
}),
|
||||||
|
lists: many(lists, {
|
||||||
|
relationName: "listsCreatedByUser",
|
||||||
|
}),
|
||||||
|
deletedWorkspaces: many(workspaces, {
|
||||||
|
relationName: "workspaceDeletedByUser",
|
||||||
|
}),
|
||||||
|
workspaces: many(workspaces, {
|
||||||
|
relationName: "workspaceCreatedByUser",
|
||||||
|
}),
|
||||||
apiKeys: many(apikey),
|
apiKeys: many(apikey),
|
||||||
|
integrations: many(integrations),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersToWorkspacesRelations = relations(
|
export const usersToWorkspacesRelations = relations(
|
||||||
@@ -43,18 +65,22 @@ export const usersToWorkspacesRelations = relations(
|
|||||||
addedBy: one(users, {
|
addedBy: one(users, {
|
||||||
fields: [workspaceMembers.createdBy],
|
fields: [workspaceMembers.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesAddedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [workspaceMembers.deletedBy],
|
fields: [workspaceMembers.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesDeletedByUser",
|
||||||
}),
|
}),
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [workspaceMembers.userId],
|
fields: [workspaceMembers.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesUser",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [workspaceMembers.workspaceId],
|
fields: [workspaceMembers.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "usersToWorkspacesWorkspace",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,17 +36,28 @@ export const workspaces = pgTable("workspace", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||||
user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
|
user: one(users, {
|
||||||
|
fields: [workspaces.createdBy],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: "workspaceCreatedByUser",
|
||||||
|
}),
|
||||||
|
deletedBy: one(users, {
|
||||||
|
fields: [workspaces.deletedBy],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: "workspaceDeletedByUser",
|
||||||
|
}),
|
||||||
members: many(workspaceMembers),
|
members: many(workspaceMembers),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
}));
|
}));
|
||||||
@@ -55,7 +66,7 @@ export const workspaceMembers = pgTable("workspace_members", {
|
|||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
email: varchar("email", { length: 255 }).notNull(),
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
userId: uuid("userId").references(() => users.id),
|
userId: uuid("userId").references(() => users.id, { onDelete: "set null" }),
|
||||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
@@ -63,7 +74,9 @@ export const workspaceMembers = pgTable("workspace_members", {
|
|||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
role: memberRoleEnum("role").notNull(),
|
role: memberRoleEnum("role").notNull(),
|
||||||
status: memberStatusEnum("status").default("invited").notNull(),
|
status: memberStatusEnum("status").default("invited").notNull(),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
@@ -74,10 +87,12 @@ export const workspaceMembersRelations = relations(
|
|||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [workspaceMembers.userId],
|
fields: [workspaceMembers.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "workspaceMembersUser",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [workspaceMembers.workspaceId],
|
fields: [workspaceMembers.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "workspaceMembersWorkspace",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import nodemailer from "nodemailer";
|
|||||||
|
|
||||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||||
import MagicLinkTemplate from "./templates/magic-link";
|
import MagicLinkTemplate from "./templates/magic-link";
|
||||||
|
import ResetPasswordTemplate from "./templates/reset-password";
|
||||||
|
|
||||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
|
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||||
|
|
||||||
const emailTemplates: Record<Templates, React.FC> = {
|
const emailTemplates: Record<Templates, React.FC> = {
|
||||||
MAGIC_LINK: MagicLinkTemplate,
|
MAGIC_LINK: MagicLinkTemplate,
|
||||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||||
|
RESET_PASSWORD: ResetPasswordTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
|
|||||||
108
packages/email/src/templates/reset-password.tsx
Normal file
108
packages/email/src/templates/reset-password.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Body } from "@react-email/body";
|
||||||
|
import { Button } from "@react-email/button";
|
||||||
|
import { Container } from "@react-email/container";
|
||||||
|
import { Head } from "@react-email/head";
|
||||||
|
import { Heading } from "@react-email/heading";
|
||||||
|
import { Hr } from "@react-email/hr";
|
||||||
|
import { Html } from "@react-email/html";
|
||||||
|
import { Link } from "@react-email/link";
|
||||||
|
import { Preview } from "@react-email/preview";
|
||||||
|
import { Text } from "@react-email/text";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
|
export const ResetPasswordTemplate = ({
|
||||||
|
resetPasswordUrl,
|
||||||
|
resetPasswordToken,
|
||||||
|
}: {
|
||||||
|
resetPasswordUrl?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
}) => (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Reset your Kan password</Preview>
|
||||||
|
<Body style={{ backgroundColor: "white" }}>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
||||||
|
margin: "auto",
|
||||||
|
paddingLeft: "0.75rem",
|
||||||
|
paddingRight: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
marginBottom: "2.5rem",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#232323",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
kan.bn
|
||||||
|
</Heading>
|
||||||
|
<Heading
|
||||||
|
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
|
||||||
|
>
|
||||||
|
Reset your Kan password
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
color: "#232323",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Click the button below to reset your password.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
target="_blank"
|
||||||
|
href={resetPasswordUrl}
|
||||||
|
style={{
|
||||||
|
marginBottom: "2rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "#282828",
|
||||||
|
paddingLeft: "1.5rem",
|
||||||
|
paddingRight: "1.5rem",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
paddingBottom: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
lineHeight: "1",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset your password
|
||||||
|
</Button>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#7e7e7e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
If you didn't try to reset your password, you can safely ignore this email.
|
||||||
|
</Text>
|
||||||
|
<Hr
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
borderWidth: "1px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: "#7e7e7e" }}>
|
||||||
|
<Link
|
||||||
|
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||||
|
target="_blank"
|
||||||
|
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
Kan
|
||||||
|
</Link>
|
||||||
|
, the open source Trello alternative.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ResetPasswordTemplate;
|
||||||
1172
pnpm-lock.yaml
generated
1172
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
46
turbo.json
46
turbo.json
@@ -3,49 +3,28 @@
|
|||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"topo": {
|
"topo": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo"]
|
||||||
"^topo"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json",
|
|
||||||
"dist/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^dev"],
|
||||||
"^dev"
|
|
||||||
],
|
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": false
|
"persistent": false
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"outputs": [
|
"outputs": [".cache/.prettiercache"],
|
||||||
".cache/.prettiercache"
|
|
||||||
],
|
|
||||||
"outputLogs": "new-only"
|
"outputLogs": "new-only"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/.eslintcache"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/.eslintcache"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/tsbuildinfo.json"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@@ -68,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",
|
||||||
@@ -117,13 +98,14 @@
|
|||||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||||
"NEXT_PUBLIC_STORAGE_URL",
|
"NEXT_PUBLIC_STORAGE_URL",
|
||||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||||
|
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
|
||||||
|
"NEXT_PUBLIC_DISABLE_SIGN_UP",
|
||||||
"S3_REGION",
|
"S3_REGION",
|
||||||
"S3_ACCESS_KEY_ID",
|
"S3_ACCESS_KEY_ID",
|
||||||
"S3_SECRET_ACCESS_KEY",
|
"S3_SECRET_ACCESS_KEY",
|
||||||
"S3_ENDPOINT",
|
"S3_ENDPOINT",
|
||||||
"PORT",
|
"PORT",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"BETTER_AUTH_URL",
|
|
||||||
"BETTER_AUTH_TRUSTED_ORIGINS"
|
"BETTER_AUTH_TRUSTED_ORIGINS"
|
||||||
],
|
],
|
||||||
"globalPassThroughEnv": [
|
"globalPassThroughEnv": [
|
||||||
@@ -134,4 +116,4 @@
|
|||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
"npm_lifecycle_event"
|
"npm_lifecycle_event"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user