Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
ec17d76ebf fix: close self hosting docs loop 2025-06-08 16:13:32 +01:00
61 changed files with 499 additions and 9451 deletions

View File

@@ -1,40 +1,24 @@
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-) POSTGRES_URL=
# Required environment variables EMAIL_FROM=
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# Only required if deploying from compose
POSTGRES_PASSWORD=
# SMTP (optional)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=465 SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
# S3 storage (optional) NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
S3_REGION= S3_REGION=
S3_ENDPOINT= S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
# Auth config (optional) BETTER_AUTH_SECRET=
NEXT_PUBLIC_ALLOW_CREDENTIALS= BETTER_AUTH_URL=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS= BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=

3
.gitignore vendored
View File

@@ -46,6 +46,3 @@ dist/
# docker # docker
docker-compose.override.yml docker-compose.override.yml
# runtime env
__ENV.js

127
README.md
View File

@@ -47,67 +47,34 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database. ### PostgreSQL Database Setup
1. Create a new file called `docker-compose.yml` and paste the following configuration: Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
```bash ```bash
docker compose up -d # Run PostgreSQL in a container using the official postgres image
docker run -d \
--name kan-db \
-e POSTGRES_DB=kan \
-e POSTGRES_USER=kan_user \
-e POSTGRES_PASSWORD=your_secure_password \
-p 5432:5432 \
-v kan_postgres_data:/var/lib/postgresql/data \
postgres:15
# Your POSTGRES_URL should be:
# postgres://kan_user:your_secure_password@your_host:5432/kan
``` ```
3. Access Kan at http://localhost:3000 ### Kan Application Deployment
The application will be running in the background. You can manage the containers using these commands: Deploy Kan with Docker using our pre-built image:
- To stop the containers: `docker compose down` ```bash
- To view logs: `docker compose logs -f` docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
- To restart the containers: `docker compose restart` ```
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository. Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
## Local Development 🧑‍💻 ## Local Development 🧑‍💻
@@ -138,35 +105,31 @@ 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 | For Email | `"Kan <hello@mail.kan.bn>"` | | `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | | `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` | | `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` | | `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` | | `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` | | `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.

View File

@@ -3,25 +3,31 @@ title: Trello
description: "Import your Trello boards to Kan." description: "Import your Trello boards to Kan."
--- ---
## Authorizing Kan.bn for Trello ## Getting your Trello API key and token
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes): 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):
1. **Authorize Kan.bn for Trello** 1. **Sign in to your Trello account**
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**. Go to [https://trello.com](https://trello.com) and log in with your Trello account.
2. **Log in to your Trello account** 2. **Create a new integration**
You will be redirected to Trello to 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:
3. **Authorize Kan.bn for Trello** - **Name**: Choose a name for your integration (e.g. "Kan Import")
Approve the permissions and you will be redirected back to Kan.bn. - **Workspace**: Select the workspace containing the boards you want to import
- **Email**: Enter your email address
- **Support email**: Enter your email address
- **Author**: Add your name
4. **Verify the connection** 3. **Get a Trello API Key**
You should see a **Disconnect Trello** button. Now click **Generate a new API key** to create an API key.
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 authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps: Once you have your API key and token, 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**.
@@ -29,5 +35,8 @@ Once you have authorized Kan.bn for Trello, importing your Trello boards is easy
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. **Start the Import** 3. **Enter Your Trello API Key and Token**
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.

View File

@@ -53,14 +53,8 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
# Copy source code of isolated subworkspace # Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT} RUN pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application # 4. Final image - runner stage to run the application
FROM base AS runner FROM base AS runner
ARG APP_DIRNAME ARG APP_DIRNAME
@@ -81,5 +75,6 @@ ARG PORT=3000
ENV PORT=${PORT} ENV PORT=${PORT}
EXPOSE ${PORT} EXPOSE ${PORT}
# Run migration on start for now (until we have a better way to handle this)
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"] CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]

View File

@@ -1,23 +0,0 @@
#!/bin/sh
# Run migrations from the Turborepo root context
echo "Running database migrations..."
# Navigate to the Turborepo root to run the db:migrate command
pnpm --prefix /app db:migrate
# Check if migration was successful
if [ $? -ne 0 ]; then
echo "\nDatabase migration failed! Exiting."
exit 1
fi
echo "\nStarting Next.js application..."
# Check if we should use standalone mode
if [ "$NEXT_PUBLIC_USE_STANDALONE_OUTPUT" = "true" ]; then
echo "Starting in standalone mode..."
exec node .next/standalone/apps/web/server.js
else
echo "Starting in standard mode..."
exec pnpm start
fi

View File

@@ -10,10 +10,6 @@ configureRuntimeEnv();
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output:
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
? "standalone"
: undefined,
reactStrictMode: true, reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */ /** Enables hot reloading for local packages without a build step */

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "pnpm with-env next build", "build": "pnpm next build",
"clean": "git clean -xdf .cache .next .turbo node_modules", "clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev | pino-pretty", "dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
@@ -23,13 +23,7 @@
"@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:",
@@ -42,18 +36,15 @@
"next-runtime-env": "^1.7.2", "next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0", "nextjs-cors": "^2.2.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"posthog-js": "^1.254.0",
"react": "catalog:react18", "react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"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": "^5.5.0", "react-icons": "^4.12.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
apps/web/public/__ENV.js Normal file
View File

@@ -0,0 +1 @@
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"};

View File

@@ -1,52 +1,27 @@
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 { import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
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({ const EmailSchema = z.object({ email: z.string().email() });
name: z.string().optional(),
email: z.string().email(),
password: z.string().optional(),
});
const availableSocialProviders = { const availableSocialProviders = {
google: { google: {
@@ -139,22 +114,19 @@ const availableSocialProviders = {
name: "Zoom", name: "Zoom",
icon: SiZoom, icon: SiZoom,
}, },
}; }
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
useState<null | SocialProvider>(null); null | SocialProvider
const isCredentialsEnabled = >(null);
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),
}); });
@@ -164,67 +136,27 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
queryFn: () => authClient.getSocialProviders(), queryFn: () => authClient.getSocialProviders(),
}); });
const handleLoginWithEmail = async ( const handleLoginWithEmail = async (email: string) => {
email: string,
password?: string,
name?: string,
) => {
setIsLoginWithEmailPending(true); setIsLoginWithEmailPending(true);
setLoginError(null); setLoginError(null);
if (password) { const { error } = await authClient.signIn.magicLink({
if (isSignUp && name) { email,
await authClient.signUp.email( callbackURL: "/boards",
{ });
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 (provider: SocialProvider) => { const handleLoginWithProvider = async (
provider: SocialProvider) => {
setIsLoginWithProviderPending(provider); setIsLoginWithProviderPending(provider);
setLoginError(null); setLoginError(null);
const { error } = await authClient.signIn.social({ const { error } = await authClient.signIn.social({
@@ -242,11 +174,9 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
}; };
const onSubmit = async (values: FormValues) => { const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email, values.password, values.name); await handleLoginWithEmail(values.email);
}; };
const password = watch("password");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{socialProviders?.length !== 0 && ( {socialProviders?.length !== 0 && (
@@ -256,17 +186,16 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: 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)}>
@@ -279,60 +208,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: 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>
)} )}
<div className="space-y-2"> <Input
{isSignUp && isCredentialsEnabled && ( {...register("email", { required: true })}
<div> placeholder="Enter your email address"
<Input />
{...register("name", { required: true })} {errors.email && (
placeholder="Enter your name" <p className="mt-2 text-xs text-red-400">
/> Please enter a valid email address
{errors.name && ( </p>
<p className="mt-2 text-xs text-red-400"> )}
Please enter a valid name {loginError && (
</p> <p className="mt-2 text-xs text-red-400">{loginError}</p>
)} )}
</div> <div className="mt-[1.5rem]">
)}
<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"
> >
{isSignUp ? "Sign up with " : "Continue with "} Continue with email
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -1,419 +0,0 @@
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>
);
}

View File

@@ -67,7 +67,6 @@ 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}
@@ -76,7 +75,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 text-xs text-light-900 dark:text-dark-900" 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"
tabIndex={-1} tabIndex={-1}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
> >

View File

@@ -15,18 +15,9 @@ export const env = createEnv({
*/ */
server: { server: {
BETTER_AUTH_SECRET: z.string(), BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z BETTER_AUTH_URL: z.string(),
.string() BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) =>
!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(),
@@ -69,7 +60,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().optional(), EMAIL_FROM: z.string(),
}, },
/** /**
@@ -79,27 +70,10 @@ 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_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(),
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()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_DISABLE_SIGN_UP: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !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.
@@ -108,16 +82,10 @@ export const env = createEnv({
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV, NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID, NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
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,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
}, },
skipValidation: skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint", !!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -2,11 +2,7 @@ import "~/styles/globals.css";
import type { AppType } from "next/app"; import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google"; import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { ModalProvider } from "~/providers/modal"; import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup"; import { PopupProvider } from "~/providers/popup";
@@ -25,21 +21,6 @@ export const metadata = {
}; };
const MyApp: AppType = ({ Component, pageProps }) => { const MyApp: AppType = ({ Component, pageProps }) => {
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
useEffect(() => {
if (posthogKey) {
posthog.init(posthogKey, {
api_host: env("NEXT_PUBLIC_POSTHOG_HOST"),
person_profiles: "identified_only",
defaults: "2025-05-24",
loaded: (posthog) => {
if (process.env.NODE_ENV === "development") posthog.debug();
},
});
}
}, [posthogKey]);
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`
@@ -51,7 +32,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
} }
`}</style> `}</style>
{env("NEXT_PUBLIC_UMAMI_ID") && ( {env("NEXT_PUBLIC_UMAMI_ID") && (
<Script <script
defer defer
src="https://cloud.umami.is/script.js" src="https://cloud.umami.is/script.js"
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")} data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
@@ -62,13 +43,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
<ThemeProvider> <ThemeProvider>
<ModalProvider> <ModalProvider>
<PopupProvider> <PopupProvider>
{posthogKey ? ( <Component {...pageProps} />
<PostHogProvider client={posthog}>
<Component {...pageProps} />
</PostHogProvider>
) : (
<Component {...pageProps} />
)}
</PopupProvider> </PopupProvider>
</ModalProvider> </ModalProvider>
</ThemeProvider> </ThemeProvider>

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,14 @@
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 { authClient } from "@kan/auth/client"; import { useRouter } from "next/navigation";
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>("");
@@ -51,14 +48,12 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{!isSignUpDisabled && ( <p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000"> Don't have an account?{" "}
Don't have an account?{" "} <span className="underline">
<span className="underline"> <Link href="/signup">Sign up</Link>
<Link href="/signup">Sign up</Link> </span>
</span> </p>
</p>
)}
</div> </div>
<PatternedBackground /> <PatternedBackground />
</div> </div>

View File

@@ -1,16 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { authClient } from "@kan/auth/client"; // import { useRouter } from "next/navigation";
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>("");
@@ -19,18 +18,15 @@ export default function SignupPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
const { data } = authClient.useSession(); // const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
if (data?.user.id) router.push("/boards"); // const { data } = api.user.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") { // if (data?.id) router.push("/boards");
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 (
<> <>
@@ -55,7 +51,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} isSignUp /> <Auth setIsMagicLinkSent={handleMagicLinkSent} />
</div> </div>
</div> </div>
)} )}

View File

@@ -12,7 +12,6 @@ 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";
@@ -116,9 +115,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",
}); });
}, },
@@ -254,12 +253,18 @@ export function NewCardForm({
/> />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<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"> <Input
<Editor placeholder="Add description..."
content="" onChange={(e) => setValue("description", e.target.value)}
onChange={(value) => setValue("description", value)} value={watch("description")}
/> contentEditable
</div> onKeyDown={async (e) => {
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">
@@ -384,12 +389,7 @@ export function NewCardForm({
/> />
<div> <div>
<Button <Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
type="submit"
disabled={title.length === 0 || createCard.isPending}
>
Create card
</Button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -2,7 +2,6 @@ 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";
@@ -21,7 +20,6 @@ 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;
@@ -40,11 +38,7 @@ const VisibilityButton = ({
const updateBoardVisibility = api.board.update.useMutation({ const updateBoardVisibility = api.board.update.useMutation({
onSuccess: () => { onSuccess: () => {
showPopup({ setStateVisibility(isPublic ? "private" : "public");
header: "Board visibility updated",
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
icon: "success",
});
}, },
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -58,39 +52,23 @@ const VisibilityButton = ({
}, },
}); });
const handleUpdateBoardVisibility = () => {
updateBoardVisibility.mutate({
visibility: isPublic ? "private" : "public",
boardPublicId,
});
};
return ( return (
<div className="relative"> <Button
<CheckboxDropdown variant="secondary"
items={[ onClick={handleUpdateBoardVisibility}
{ iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
key: "public", isLoading={updateBoardVisibility.isPending}
value: "Public", disabled={isLoading || !isAdmin}
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>
); );
}; };

View File

@@ -260,16 +260,13 @@ 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]"> <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>
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"}
@@ -357,7 +354,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 dark:scrollbar-track-dark-100 dark: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 scrollbar-track-dark-100 scrollbar-thumb-dark-600"
> >
{list.cards.map((card, index) => ( {list.cards.map((card, index) => (
<Draggable <Draggable
@@ -377,13 +374,12 @@ 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 ${ className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
card.publicId.startsWith( "PLACEHOLDER",
"PLACEHOLDER", )
) ? "pointer-events-none"
? "pointer-events-none" : ""
: "" }`}
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}

View File

@@ -1,68 +1,37 @@
import Link from "next/link"; import Link from "next/link";
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
import { Fragment, useEffect, useState } from "react"; import { Fragment, 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 Toggle from "~/components/Toggle"; import Input from "~/components/Input";
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";
const integrationProviders: Record< interface TrelloFormValues {
string, apiKey: string;
{ name: string; icon: JSX.Element } token: string;
> = { }
trello: {
name: "Trello", const sources = [{ source: "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: integrations?.[0]?.provider ?? "trello", source: "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 = () => {
if (!hasIntegrations && trelloUrl) { handleNextStep();
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else {
handleNextStep();
}
}; };
return ( return (
@@ -78,9 +47,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">
{integrationProviders[field.value]?.icon} <FaTrello />
<span className="ml-2 block truncate"> <span className="ml-2 block truncate">
{integrationProviders[field.value]?.name} {field.value}
</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">
@@ -99,41 +68,20 @@ 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">
{hasIntegrations ? ( {sources.map(({ source }, index) => (
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="trello_placeholder" key={`source_${index}`}
className="relative cursor-default select-none px-1" className="relative cursor-default select-none px-1"
value="trello" value={source}
> >
<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">
{integrationProviders.trello?.icon} <FaTrello className="ml-1" />
<span className="ml-2 block truncate font-normal"> <span className="ml-2 block truncate font-normal">
{integrationProviders.trello?.name} {source}
</span> </span>
</div> </div>
</Listbox.Option> </Listbox.Option>
)} ))}
</Listbox.Options> </Listbox.Options>
</Transition> </Transition>
</div> </div>
@@ -146,14 +94,7 @@ 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 <Button type="submit">Select source</Button>
type="submit"
iconRight={
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? "Select source" : "Connect Trello"}
</Button>
</div> </div>
</div> </div>
</form> </form>
@@ -162,26 +103,25 @@ 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 { data: boards, isLoading: boardsLoading } = const boards = api.import.trello.getBoards.useQuery(
api.import.trello.getBoards.useQuery(); { apiKey, token },
{
enabled: apiKey && token ? true : false,
},
);
const { const handleSetAuthDetails = (apiKey: string, token: string) => {
register: registerBoards, setApiKey(apiKey);
handleSubmit: handleSubmitBoards, setToken(token);
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 () => {
@@ -206,94 +146,83 @@ const ImportTrello: React.FC = () => {
}, },
}); });
const boardWatchers = boards?.map((board) => ({ const { register, handleSubmit } = useForm<TrelloFormValues>({
id: board.id, defaultValues: {
value: watch(board.id), apiKey: "",
})); token: "",
},
});
const onSubmit = (values: TrelloFormValues) => {
handleSetAuthDetails(values.apiKey, values.token);
};
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
useForm({
defaultValues: Object.fromEntries(
boards.data?.map((board) => [board.id, true]) ?? [],
),
});
const 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,
}); });
}; };
const renderContent = () => { if (boards.data?.length)
if (boardsLoading) { return (
return ( <form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="flex h-full w-full flex-col items-center justify-center gap-1"> <div className="h-[105px] overflow-scroll px-5">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" /> {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
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>
);
}
if (!boards?.length) { <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
return ( <div>
<div className="flex h-full w-full items-center justify-center"> <Button type="submit" isLoading={importBoards.isPending}>
<p className="text-sm text-neutral-500 dark:text-dark-900"> Import boards
No boards found </Button>
</p> </div>
</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 onSubmit={handleSubmitBoards(onSubmitBoards)}> <form
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div> onSubmit={handleSubmit(onSubmit)}
className="text-neutral-900 dark:text-dark-1000"
>
<div className="space-y-4 px-5">
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
<Input id="token" placeholder="Token" {...register("token")} />
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <div>
label="Select all" <Button type="submit" isLoading={boards.isLoading}>
isChecked={!!isSelectAllEnabled} Fetch boards
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>

View File

@@ -132,11 +132,7 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<ContentEditable <p className="mt-2 text-sm">{comment}</p>
html={comment ?? ""}
disabled={true}
className="mt-2 text-sm"
/>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable <ContentEditable

View File

@@ -1,5 +1,6 @@
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";
@@ -23,7 +24,6 @@ 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,8 +137,6 @@ export default function CardPage() {
}); });
}; };
const description = watch("description");
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
@@ -201,10 +199,13 @@ export default function CardPage() {
className="w-full space-y-6" className="w-full space-y-6"
> >
<div className="mt-2"> <div className="mt-2">
<Editor <ContentEditable
content={card.description} placeholder="Add description..."
onChange={(e) => setValue("description", e)} html={watch("description")}
onBlur={() => handleSubmit(onSubmit)()} disabled={false}
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>

View File

@@ -1,7 +1,5 @@
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";
@@ -18,7 +16,6 @@ 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(
{ {
@@ -93,7 +90,9 @@ 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">
<Editor content={data.description} readOnly /> <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">
{data.description}
</p>
</div> </div>
</div> </div>
)} )}

View File

@@ -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 dark:scrollbar-track-dark-100 dark: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 scrollbar-track-dark-100 scrollbar-thumb-dark-600">
{list.cards.map((card) => ( {list.cards.map((card) => (
<Link <Link
key={card.publicId} key={card.publicId}

View File

@@ -1,101 +0,0 @@
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>
);
}

View File

@@ -1,5 +1,4 @@
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";
@@ -7,13 +6,11 @@ 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";
@@ -24,54 +21,9 @@ 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 () => {
@@ -162,48 +114,6 @@ 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
@@ -217,40 +127,20 @@ export default function SettingsPage() {
/> />
</div> </div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <div className="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="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete workspace Delete workspace
</h2> </h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900"> <p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your workspace, there is no going back. This Once you delete your workspace, there is no going back. Please
action cannot be undone. be certain.
</p> </p>
<div className="mt-4"> <Button
<Button variant="primary"
variant="secondary" onClick={() => openModal("DELETE_WORKSPACE")}
onClick={() => openModal("DELETE_WORKSPACE")} >
> Delete workspace
Delete workspace </Button>
</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>
@@ -262,9 +152,6 @@ 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>

View File

@@ -5,7 +5,6 @@ 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: {

View File

@@ -1,64 +0,0 @@
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
ports:
- "${WEB_PORT:-3000}:3000"
networks:
- dokploy-network
build:
context: ..
dockerfile: apps/web/Dockerfile
env_file:
- .env
restart: unless-stopped
environment:
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
# SMTP
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE}
- EMAIL_FROM=${EMAIL_FROM}
# S3 storage
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# Auth config
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# Integration providers
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
# OAuth providers (optional)
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
# Analytics
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
networks:
dokploy-network:
external: true
#bump

View File

@@ -1,49 +1,30 @@
version: "3.7"
services: services:
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
ports: ports:
- "${WEB_PORT:-3000}:3000" - "3001:3000"
networks: networks:
- kan-network
- dokploy-network - dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile.new dockerfile: ./apps/web/Dockerfile
env_file: env_file:
- .env - .env
command: ["pnpm", "start"]
environment: environment:
# Required environment variables - EMAIL_FROM=${EMAIL_FROM}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
# SMTP (optional)
- SMTP_HOST=${SMTP_HOST} - SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAIL_FROM=${EMAIL_FROM} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
# S3 storage (optional)
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# Integration providers (optional)
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
# OAuth providers (optional)
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- 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}
@@ -81,29 +62,16 @@ services:
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID} - APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on: - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- postgres - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
restart: unless-stopped - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
postgres: - NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
image: postgres:15 - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
container_name: kan-db - NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
environment: - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- POSTGRES_DB=kan_db - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- POSTGRES_USER=kan - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks: networks:
kan-network:
dokploy-network: dokploy-network:
external: true external: true
volumes:
kan_postgres_data:

View File

@@ -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", "Integrations"], tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
}); });

View File

@@ -2,7 +2,6 @@ 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";
@@ -20,7 +19,6 @@ 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;

View File

@@ -5,7 +5,6 @@ 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";
@@ -14,9 +13,10 @@ 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";
export interface TrelloBoard { const TRELLO_API_URL = "https://api.trello.com/1";
interface TrelloBoard {
id: string; id: string;
name: string; name: string;
labels: TrelloLabel[]; labels: TrelloLabel[];
@@ -42,6 +42,10 @@ 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
@@ -49,51 +53,46 @@ export const importRouter = createTRPCRouter({
openapi: { openapi: {
summary: "Get boards from Trello", summary: "Get boards from Trello",
method: "GET", method: "GET",
path: "/integrations/trello/boards", path: "/imports/trello/boards",
description: "Retrieves all boards from Trello", description: "Retrieves all boards from Trello",
tags: ["Integrations"], tags: ["Imports"],
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 ({ ctx }) => { .query(async ({ input }) => {
const apiKey = apiKeys.trello; const fetchMemberRes = await fetch(
`${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 token = integration?.accessToken; const member = (await fetchMemberRes.json()) as MemberData;
if (!token) const boardIds = member.idBoards;
throw new TRPCError({
message: "Trello token not found",
code: "UNAUTHORIZED",
});
const response = await fetch( const fetchBoard = async (boardId: string) => {
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`, const response = await fetch(
); `${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
);
const data = (await response.json()) as TrelloBoard;
const data = (await response.json()) as TrelloBoard[]; return data;
};
return data.map((board) => ({ const boards = [];
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,
})); }));
@@ -103,7 +102,7 @@ export const importRouter = createTRPCRouter({
openapi: { openapi: {
summary: "Import boards from Trello", summary: "Import boards from Trello",
method: "POST", method: "POST",
path: "/imports/trello/boards", path: "/imports/trello/import",
description: "Imports boards from Trello", description: "Imports boards from Trello",
tags: ["Imports"], tags: ["Imports"],
protect: true, protect: true,
@@ -112,6 +111,8 @@ 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),
}), }),
) )
@@ -119,32 +120,12 @@ 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,
@@ -169,7 +150,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(
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`, `${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}&lists=open&cards=open&labels=all`,
); );
const data = (await response.json()) as TrelloBoard; const data = (await response.json()) as TrelloBoard;

View File

@@ -1,128 +0,0 @@
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",
});
}),
});

View File

@@ -1,11 +1,10 @@
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";
@@ -13,6 +12,7 @@ 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,8 +83,7 @@ export const socialProvidersPlugin = () => ({
{ {
method: "GET", method: "GET",
}, },
async (ctx) => async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
), ),
}, },
}); });
@@ -100,13 +99,10 @@ 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.NEXT_PUBLIC_BASE_URL!, baseURL: process.env.BETTER_AUTH_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: {
@@ -114,22 +110,8 @@ 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",
@@ -166,17 +148,8 @@ export const initAuth = (db: dbClient) => {
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
before() { async after(user, _context) {
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") { if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
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") ?? "",
@@ -189,21 +162,18 @@ export const initAuth = (db: dbClient) => {
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
const fileExtension = const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
user.image.split(".").pop()?.split("?")[0] || "jpg"; const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
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( await client.send(new PutObjectCommand({
new PutObjectCommand({ Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", Key: key,
Key: key, Body: imageBuffer,
Body: imageBuffer, ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`, ACL: 'public-read',
ACL: "public-read", }));
}),
);
await userRepo.update(db, user.id, { await userRepo.update(db, user.id, {
image: key, image: key,
}); });
@@ -211,9 +181,9 @@ export const initAuth = (db: dbClient) => {
console.error(error); console.error(error);
} }
} }
}, }
}, }
}, }
}, },
hooks: { hooks: {
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {

View File

@@ -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 $$;

View File

@@ -1,17 +0,0 @@
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 $$;

View File

@@ -1,152 +0,0 @@
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 $$;

View File

@@ -1,17 +0,0 @@
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 $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,27 +29,6 @@
"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
} }
] ]
} }

View File

@@ -1,59 +0,0 @@
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)),
);
};

View File

@@ -81,6 +81,5 @@ 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",
}), }),
})); }));

View File

@@ -33,15 +33,13 @@ 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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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,
), ),
@@ -62,23 +60,19 @@ 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",
}), }),
})); }));

View File

@@ -44,15 +44,13 @@ 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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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" }),
@@ -63,24 +61,20 @@ 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),
@@ -96,29 +90,23 @@ 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, () => lists.id, { onDelete: "cascade" },
{ onDelete: "cascade" },
), ),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
onDelete: "cascade", labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { 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: "set null" }), }).references(() => workspaceMembers.id, { onDelete: "cascade" }),
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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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, () => comments.id, { onDelete: "cascade" },
{ onDelete: "cascade" },
), ),
fromComment: text("fromComment"), fromComment: text("fromComment"),
toComment: text("toComment"), toComment: text("toComment"),
@@ -128,42 +116,34 @@ 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",
}), }),
})); }));
@@ -184,12 +164,10 @@ 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",
}), }),
})); }));
@@ -212,12 +190,10 @@ 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",
}), }),
}), }),
); );
@@ -229,31 +205,26 @@ 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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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, { deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "cascade" }),
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",
}), }),
})); }));

View File

@@ -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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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,6 +26,5 @@ 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",
}), }),
})); }));

View File

@@ -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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}).enableRLS(); }).enableRLS();
@@ -36,7 +36,6 @@ 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),

View File

@@ -7,5 +7,4 @@ 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";

View File

@@ -1,40 +0,0 @@
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],
}),
}));

View File

@@ -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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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,21 +28,17 @@ 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],
@@ -52,6 +48,5 @@ 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",
}), }),
})); }));

View File

@@ -19,15 +19,13 @@ 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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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" }),
@@ -38,22 +36,18 @@ 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",
}), }),
})); }));

View File

@@ -13,7 +13,6 @@ 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")
@@ -30,33 +29,12 @@ export const users = pgTable("user", {
}).enableRLS(); }).enableRLS();
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
deletedBoards: many(boards, { boards: many(boards),
relationName: "boardDeletedByUser", cards: many(cards),
}),
boards: many(boards, {
relationName: "boardCreatedByUser",
}),
deletedCards: many(cards, {
relationName: "cardsDeletedByUser",
}),
cards: many(cards, {
relationName: "cardsCreatedByUser",
}),
imports: many(imports), imports: many(imports),
deletedLists: many(lists, { lists: many(lists),
relationName: "listsDeletedByUser", workspaces: many(workspaces),
}),
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(
@@ -65,22 +43,18 @@ 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",
}), }),
}), }),
); );

View File

@@ -36,28 +36,17 @@ 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").references(() => users.id, { createdBy: uuid("createdBy")
onDelete: "set null", .notNull()
}), .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, { user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
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),
})); }));
@@ -66,7 +55,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, { onDelete: "set null" }), userId: uuid("userId").references(() => users.id),
workspaceId: bigint("workspaceId", { mode: "number" }) workspaceId: bigint("workspaceId", { mode: "number" })
.notNull() .notNull()
.references(() => workspaces.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
@@ -74,9 +63,7 @@ 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();
@@ -87,12 +74,10 @@ 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",
}), }),
}), }),
); );

View File

@@ -3,27 +3,22 @@ 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" | "RESET_PASSWORD"; type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
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({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE !== "false", secure: true,
...(process.env.SMTP_USER && auth: {
process.env.SMTP_PASSWORD && { user: process.env.SMTP_USER,
auth: { pass: process.env.SMTP_PASSWORD,
user: process.env.SMTP_USER, },
pass: process.env.SMTP_PASSWORD,
},
}),
}); });
export const sendEmail = async ( export const sendEmail = async (
@@ -32,34 +27,22 @@ export const sendEmail = async (
template: Templates, template: Templates,
data: Record<string, string>, data: Record<string, string>,
) => { ) => {
try { const EmailTemplate = emailTemplates[template];
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true }); const html = await render(<EmailTemplate {...data} />, { pretty: true });
const options = { const options = {
from: process.env.EMAIL_FROM, from: process.env.EMAIL_FROM,
to, to,
subject, subject,
html, html,
}; };
const response = await transporter.sendMail(options); const response = await transporter.sendMail(options);
if (!response.accepted.length) { if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`); throw new Error(`Failed to send email: ${response.response}`);
}
return response;
} catch (error) {
console.error("Email sending failed:", {
to,
from: process.env.EMAIL_FROM,
subject,
template,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
} }
return response;
}; };

View File

@@ -1,108 +0,0 @@
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&apos;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;

1213
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,49 @@
"ui": "tui", "ui": "tui",
"tasks": { "tasks": {
"topo": { "topo": {
"dependsOn": ["^topo"] "dependsOn": [
"^topo"
]
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json", "dist/**"] "^build"
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
}, },
"dev": { "dev": {
"dependsOn": ["^dev"], "dependsOn": [
"^dev"
],
"cache": false, "cache": false,
"persistent": false "persistent": false
}, },
"format": { "format": {
"outputs": [".cache/.prettiercache"], "outputs": [
".cache/.prettiercache"
],
"outputLogs": "new-only" "outputLogs": "new-only"
}, },
"lint": { "lint": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/.eslintcache"] "^topo",
"^build"
],
"outputs": [
".cache/.eslintcache"
]
}, },
"typecheck": { "typecheck": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json"] "^topo",
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
}, },
"clean": { "clean": {
"cache": false "cache": false
@@ -47,8 +68,6 @@
}, },
"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",
@@ -91,7 +110,6 @@
"SMTP_PORT", "SMTP_PORT",
"SMTP_USER", "SMTP_USER",
"SMTP_PASSWORD", "SMTP_PASSWORD",
"SMTP_SECURE",
"NEXT_PUBLIC_KAN_ENV", "NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET",
@@ -99,15 +117,13 @@
"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",
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
"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": [
@@ -118,4 +134,4 @@
"VERCEL_URL", "VERCEL_URL",
"npm_lifecycle_event" "npm_lifecycle_event"
] ]
} }