Compare commits

..

35 Commits

Author SHA1 Message Date
Henry
9303fcb7e0 feat: render URLs as links in card description 2025-06-21 23:14:25 +01:00
Henry
500fe8af95 feat: make SMTP auth optional and add SMTP_SECURE config (#92)
* feat: add SMTP_SECURE option to email config

* feat: allow unauthenticated SMTP servers
2025-06-19 22:58:13 +01:00
Henry
e7383ec670 feat: add debug logs to sendEmail (#91) 2025-06-19 21:55:01 +01:00
Henry
5586c26c12 fix: use default output mode for next 2025-06-18 23:18:12 +01:00
Henry
2d7097eb83 chore: cleanup test dockerfile 2025-06-18 23:11:29 +01:00
Henry
2aa898da00 feat: enable starting in standalone mode 2025-06-18 22:57:12 +01:00
Henry
a01a826bac fix: build with env vars 2025-06-18 22:49:09 +01:00
Henry
a29ea76024 fix: update build context in compose 2025-06-18 22:34:13 +01:00
Henry
e1c6bfd6cd fix: add test compose file for cloud 2025-06-18 22:27:46 +01:00
Henry
c272c38f6b feat: reduce docker image size by using next standalone output (#89) 2025-06-18 22:16:50 +01:00
khalt00
02d3d300fb change volume name (#88)
Co-authored-by: Le Trong Kha <kha.le@ao360.com.vn>
2025-06-17 23:20:15 +01:00
Henry
0bd28f9f95 fix: set container name env var 2025-06-17 23:02:24 +01:00
Henry
d0c2314ebf feat: add posthog analytics 2025-06-17 22:45:06 +01:00
Henry
268871c348 fix: bump compose 2025-06-17 11:00:28 +01:00
Henry
2e11199764 fix: add old docker compose and revert entrypoint 2025-06-17 10:57:54 +01:00
Henry
fc6bb22400 fix: copy static files and use explicit shell in Docker (#86) (#87)
* fix: copy static files to standalone dir

* fix: update Dockerfile to use /bin/sh for entrypoint execution

* fix: update Dockerfile to copy static files from apps/web directory

* fix: copy from root

* fix: set workdir

* fix: copy files from inside the container

* fix: add logs to debug

* fix: remove workdir

* fix: log contents of build

* fix: copy static files

* fix: remove log

* chore: remove spacing
2025-06-16 22:50:21 +01:00
Henry
1c75a948f9 feat: setup postgres with docker compose (#85)
* feat: add postgres to docker compose

* feat: run migrations via entrypoint

* chore: tidy .env.example

* fix: allow empty strings for optional envs

* fix: remove external from kan-network

* chore: update readme
2025-06-14 22:52:27 +01:00
Henry
3a48c797b7 feat: set web port with env variable 2025-06-13 22:03:57 +01:00
Henry
b838e8040a fix: remove version from docker compose (#81) 2025-06-13 21:43:05 +01:00
Henry
f0e3c4c3e2 fix: render comments inside contenteditable (#79) 2025-06-13 09:23:55 +01:00
Henry
d6b0b5cd80 Revert "chore: optimize Docker build with multi-stage process and add standalone Next.js output (#75)"s
This reverts commit 1707557bea.
2025-06-12 22:26:59 +01:00
LovelessCodes
1707557bea chore: optimize Docker build with multi-stage process and add standalone Next.js output (#75) 2025-06-12 22:12:24 +01:00
Henry
f6114a56af chore: patch migration order (#74) 2025-06-10 21:15:47 +01:00
LovelessCodes
c1d0bb3c93 fix: make onChange prop optional in Editor and rename markdown prop to content (#72) 2025-06-10 19:26:38 +01:00
LovelessCodes
09ae4292ac refactor: replace router.push with permanentRedirect for disabled sig… (#73)
* refactor: replace router.push with permanentRedirect for disabled signup flow

* feat: replace signup redirect with custom disabled signup UI
2025-06-10 19:25:48 +01:00
LovelessCodes
75e89f118f feat: implement Trello integration with OAuth and user fields (#48)
* feat: implement Trello integration with OAuth and user fields

* feat: migrate Trello integration to new integrations table

* refactor: migrate Trello integration to use new integration system

* refactor: migrate Trello integration to use new integrations table

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

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

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

* feat: add Trello import env vars on README

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

* feat: add integrations table for OAuth token management

* feat: connect trello from import modal

* refactor: consolidate Trello integration endpoints into unified integration router

* fix: trello import again

* refactor: generalize integration authorization flow and improve error handling

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

* feat: add Integrations tag to OpenAPI specification

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

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

* refactor: consolidate Trello integration endpoints and fix API URL

* chore: remove debug logs

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-10 19:24:18 +01:00
LovelessCodes
dd6cdfc208 refactor: simplify auth configuration by using NEXT_PUBLIC_BASE_URL instead of BETTER_AUTH_URL (#66) 2025-06-09 21:37:53 +01:00
Ahmed Semih Erkan
7758bd77ea fix: add dark prefix to custom scrollbar styles (#63) 2025-06-09 21:33:08 +01:00
LovelessCodes
294ed2ffc2 feat: enhance board visibility controls with dropdown menu and success notifications (#65)
* feat: enhance board visibility controls with dropdown menu and success notifications

* feat: tweak design

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-09 21:29:52 +01:00
LovelessCodes
e2786f31b0 chore: remove runtime env file and add to ignore (#59) 2025-06-09 13:49:57 +01:00
LovelessCodes
bfc69996c6 Feat: Markdown editor for description on cards (#43)
* feat: integrate MDXEditor for card description with markdown support

* feat: replace textarea with markdown editor for card descriptions and improve styling

* refactor: replace MDXEditor with Tiptap for rich text editing

* fix: prevent menu item from taking focus and submitting

* feat: replace editor menu with bubble menu and add placeholder text

* feat: implement slash commands menu in editor with suggestion API

* chore: update editor styling

* chore: update editor placeholder text

* fix: navigate command list by keyboard and tweak styling

* fix: prevent editor onBlur from submitting NewCardForm

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-09 13:47:00 +01:00
LovelessCodes
96f2835bab Feat: allow email password sign in/up for selfhosters (#54)
* feat: add password reset functionality with email template and credentials config

* feat: add authentication configuration options and improve development setup

* feat: add password-based authentication and signup control flags

* feat: update auth form to support name field

* fix: prevent password icon from overlaying the input text

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-08 21:25:37 +01:00
LovelessCodes
53426bf155 feat: add account deletion functionality with cascade delete for work… (#33)
* feat: add account deletion functionality with cascade delete for workspace relations

* fix: add cascade delete to workspace user foreign key constraints

* fix: change onDelete action to set null for user references in schema

* feat: tweak design

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
2025-06-08 20:54:47 +01:00
LovelessCodes
17c2a1b8ec refactor: add relation names to schema (#56) 2025-06-08 16:32:44 +01:00
Henry
d203f834cc fix: close self hosting docs loop (#58) 2025-06-08 16:15:21 +01:00
61 changed files with 9482 additions and 498 deletions

View File

@@ -1,24 +1,40 @@
POSTGRES_URL=
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
EMAIL_FROM=
# Required environment variables
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_PORT=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=

3
.gitignore vendored
View File

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

125
README.md
View File

@@ -47,34 +47,67 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳
### PostgreSQL Database Setup
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```bash
# 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
```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
# Your POSTGRES_URL should be:
# postgres://kan_user:your_secure_password@your_host:5432/kan
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:
```
### Kan Application Deployment
Deploy Kan with Docker using our pre-built image:
2. Start the containers in detached mode:
```bash
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
docker compose up -d
```
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
3. Access Kan at http://localhost:3000
The application will be running in the background. You can manage the containers using these commands:
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To restart the containers: `docker compose restart`
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **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 🧑‍💻
@@ -105,31 +138,35 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `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_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `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_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | 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_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `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` |
| Variable | Description | Required | Example |
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `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` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `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` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | 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_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `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.

View File

@@ -3,31 +3,25 @@ title: Trello
description: "Import your Trello boards to Kan."
---
## Getting your Trello API key and token
## Authorizing Kan.bn for Trello
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes):
1. **Sign in to your Trello account**
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
1. **Authorize Kan.bn for Trello**
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
2. **Create a new integration**
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
2. **Log in to your Trello account**
You will be redirected to Trello to log in to your Trello account.
- **Name**: Choose a name for your integration (e.g. "Kan Import")
- **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
3. **Authorize Kan.bn for Trello**
Approve the permissions and you will be redirected back to Kan.bn.
3. **Get a Trello API Key**
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).
4. **Verify the connection**
You should see a **Disconnect Trello** button.
## Importing your Trello boards
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
Once you have authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps:
1. **Create a new import in Kan**
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
@@ -35,8 +29,5 @@ Once you have your API key and token, importing your Trello boards is easy. Foll
2. **Select Trello as the Import Source**
Choose **Trello** from the list of import options and click **Select source**.
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**
3. **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.

View File

@@ -53,8 +53,14 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
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
FROM base AS runner
ARG APP_DIRNAME
@@ -75,6 +81,5 @@ ARG PORT=3000
ENV PORT=${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"]

23
apps/web/entrypoint.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/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,6 +10,10 @@ configureRuntimeEnv();
/** @type {import("next").NextConfig} */
const config = {
output:
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
? "standalone"
: undefined,
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm next build",
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -23,7 +23,14 @@
"@kan/db": "workspace:^",
"@kan/shared": "workspace:^",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@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/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
@@ -36,15 +43,18 @@
"next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^4.12.0",
"react-icons": "^5.5.0",
"react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"zod": "catalog:"
},
"devDependencies": {

View File

@@ -1 +0,0 @@
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};

View File

@@ -1,27 +1,52 @@
import type { SocialProvider } from "better-auth/social-providers";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
import {
FaApple,
FaDiscord,
FaDropbox,
FaFacebook,
FaGithub,
FaGitlab,
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaReddit,
FaSpotify,
FaTiktok,
FaTwitch,
FaTwitter,
FaVk,
} from "react-icons/fa";
import { SiRoblox, SiZoom } from "react-icons/si";
import { TbBrandKick } from "react-icons/tb";
import { z } from "zod";
import type { SocialProvider } from "better-auth/social-providers";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
interface FormValues {
name?: string;
email: string;
password?: string;
}
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
}
const EmailSchema = z.object({ email: z.string().email() });
const EmailSchema = z.object({
name: z.string().optional(),
email: z.string().email(),
password: z.string().optional(),
});
const availableSocialProviders = {
google: {
@@ -114,19 +139,22 @@ const availableSocialProviders = {
name: "Zoom",
icon: SiZoom,
},
}
};
export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
null | SocialProvider
>(null);
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | SocialProvider>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormValues>({
resolver: zodResolver(EmailSchema),
});
@@ -136,27 +164,67 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (email: string) => {
const handleLoginWithEmail = async (
email: string,
password?: string,
name?: string,
) => {
setIsLoginWithEmailPending(true);
setLoginError(null);
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/boards",
});
if (password) {
if (isSignUp && name) {
await authClient.signUp.email(
{
name,
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been signed up successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
} else {
await authClient.signIn.email(
{
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been logged in successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
}
} else {
await authClient.signIn.magicLink(
{
email,
callbackURL: "/boards",
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
}
setIsLoginWithEmailPending(false);
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);
setLoginError(null);
const { error } = await authClient.signIn.social({
@@ -174,9 +242,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
};
const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email);
await handleLoginWithEmail(values.email, values.password, values.name);
};
const password = watch("password");
return (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
@@ -186,16 +256,17 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
return null;
}
return (
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
)})}
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
);
})}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
@@ -208,26 +279,60 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<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>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
<div className="mt-[1.5rem]">
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder="Enter your name"
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid name
</p>
)}
</div>
)}
<div>
<Input
{...register("email", { required: true })}
placeholder="Enter your email address"
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder="Enter your password"
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid password
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
Continue with email
{isSignUp ? "Sign up with " : "Continue with "}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
</Button>
</div>
</form>

View File

@@ -0,0 +1,430 @@
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 Link from "@tiptap/extension-link";
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)",
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
target: "_blank",
rel: "noopener noreferrer",
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
}),
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,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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",
prefix && "rounded-l-none",
type === "password" && "pr-8",
className && className,
)}
onKeyDown={onKeyDown}
@@ -75,7 +76,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{type === "password" && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900"
tabIndex={-1}
onClick={() => setShowPassword((v) => !v)}
>

View File

@@ -15,9 +15,18 @@ export const env = createEnv({
*/
server: {
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_URL: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z.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(),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
@@ -60,7 +69,7 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
EMAIL_FROM: z.string(),
EMAIL_FROM: z.string().optional(),
},
/**
@@ -70,10 +79,27 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: 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_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.
@@ -82,10 +108,16 @@ export const env = createEnv({
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
NODE_ENV: process.env.NODE_ENV,
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_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
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:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -2,7 +2,11 @@ import "~/styles/globals.css";
import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
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 { PopupProvider } from "~/providers/popup";
@@ -21,6 +25,21 @@ export const metadata = {
};
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 (
<>
<style jsx global>{`
@@ -32,7 +51,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
}
`}</style>
{env("NEXT_PUBLIC_UMAMI_ID") && (
<script
<Script
defer
src="https://cloud.umami.is/script.js"
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
@@ -43,7 +62,13 @@ const MyApp: AppType = ({ Component, pageProps }) => {
<ThemeProvider>
<ModalProvider>
<PopupProvider>
<Component {...pageProps} />
{posthogKey ? (
<PostHogProvider client={posthog}>
<Component {...pageProps} />
</PostHogProvider>
) : (
<Component {...pageProps} />
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,17 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { authClient } from "@kan/auth/client";
export default function LoginPage() {
const router = useRouter();
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -48,12 +51,14 @@ export default function LoginPage() {
</div>
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
{!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
)}
</div>
<PatternedBackground />
</div>

View File

@@ -1,15 +1,16 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
// import { useRouter } from "next/navigation";
import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
// import { api } from "~/utils/api";
import { env } from "next-runtime-env";
export default function SignupPage() {
// const router = useRouter();
const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -18,15 +19,18 @@ export default function SignupPage() {
setMagicLinkRecipient(recipient);
};
// const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
const { data } = authClient.useSession();
// const { data } = api.user.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
if (data?.user.id) router.push("/boards");
// if (data?.id) router.push("/boards");
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
return (
<div className="flex flex-col bg-light-100 dark:bg-dark-50 h-screen items-center justify-center">
<p className="mb-2 text-light-1000 dark:text-dark-1000 font-semibold text-sm">Sign up is disabled</p>
<Link href="/login" className="text-light-1000 dark:text-dark-1000 font-semibold text-sm rounded-md border border-light-900 dark:border-dark-900 px-4 py-2">Login</Link>
</div>
)
}
return (
<>
@@ -51,7 +55,7 @@ export default function SignupPage() {
) : (
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
</div>
</div>
)}

View File

@@ -12,6 +12,7 @@ import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle";
@@ -115,9 +116,9 @@ export function NewCardForm({
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to create card",
message: error.data?.zodError?.fieldErrors.title?.[0] ?
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
"Please try again later, or contact customer support.",
message: error.data?.zodError?.fieldErrors.title?.[0]
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
: "Please try again later, or contact customer support.",
icon: "error",
});
},
@@ -253,18 +254,12 @@ export function NewCardForm({
/>
</div>
<div className="mt-2">
<Input
placeholder="Add description..."
onChange={(e) => setValue("description", e.target.value)}
value={watch("description")}
contentEditable
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<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">
<Editor
content=""
onChange={(value) => setValue("description", value)}
/>
</div>
</div>
<div className="mt-2 flex space-x-1">
<div className="w-fit">
@@ -389,7 +384,12 @@ export function NewCardForm({
/>
<div>
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
<Button
type="submit"
disabled={title.length === 0 || createCard.isPending}
>
Create card
</Button>
</div>
</div>
</form>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -20,6 +21,7 @@ const VisibilityButton = ({
}: {
visibility: "public" | "private";
boardPublicId: string;
boardSlug: string;
queryParams: QueryParams;
isLoading: boolean;
isAdmin: boolean;
@@ -38,7 +40,11 @@ const VisibilityButton = ({
const updateBoardVisibility = api.board.update.useMutation({
onSuccess: () => {
setStateVisibility(isPublic ? "private" : "public");
showPopup({
header: "Board visibility updated",
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
icon: "success",
});
},
onError: () => {
showPopup({
@@ -52,23 +58,39 @@ const VisibilityButton = ({
},
});
const handleUpdateBoardVisibility = () => {
updateBoardVisibility.mutate({
visibility: isPublic ? "private" : "public",
boardPublicId,
});
};
return (
<Button
variant="secondary"
onClick={handleUpdateBoardVisibility}
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
isLoading={updateBoardVisibility.isPending}
disabled={isLoading || !isAdmin}
>
{isPublic ? "Public" : "Private"}
</Button>
<div className="relative">
<CheckboxDropdown
items={[
{
key: "public",
value: "Public",
selected: isPublic,
},
{
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,13 +260,16 @@ export default function BoardPage() {
</form>
)}
{!boardData && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">Board not found</p>
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Board not found
</p>
)}
<div className="flex items-center space-x-2">
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
isLoading={!boardData}
isAdmin={workspace.role === "admin"}
@@ -354,7 +357,7 @@ export default function BoardPage() {
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
>
{list.cards.map((card, index) => (
<Draggable
@@ -374,12 +377,13 @@ export default function BoardPage() {
}}
key={card.publicId}
href={`/cards/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import Link from "next/link";
import { useRouter } from "next/router";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
@@ -24,6 +23,7 @@ import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import NewCommentForm from "./components/NewCommentForm";
import Editor from "~/components/Editor";
interface FormValues {
cardId: string;
@@ -137,6 +137,8 @@ export default function CardPage() {
});
};
const description = watch("description");
if (!cardId) return <></>;
return (
@@ -199,13 +201,10 @@ export default function CardPage() {
className="w-full space-y-6"
>
<div className="mt-2">
<ContentEditable
placeholder="Add description..."
html={watch("description")}
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"
<Editor
content={card.description}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
/>
</div>
</form>

View File

@@ -1,5 +1,7 @@
import { authClient } from "@kan/auth/client";
import { useRouter } from "next/router";
import { HiXMark } from "react-icons/hi2";
import Editor from "~/components/Editor";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
@@ -16,6 +18,7 @@ export function CardModal({
}) {
const router = useRouter();
const { closeModal, isOpen } = useModal();
const { data: session } = authClient.useSession();
const { data, isLoading } = api.card.byId.useQuery(
{
@@ -90,9 +93,7 @@ export function CardModal({
{data?.description && (
<div className="mb-10 flex w-full max-w-2xl justify-between">
<div className="mt-2">
<p className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6">
{data.description}
</p>
<Editor content={data.description} readOnly />
</div>
</div>
)}

View File

@@ -133,7 +133,7 @@ export default function PublicBoardView() {
{list.name}
</span>
</div>
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600">
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
{list.cards.map((card) => (
<Link
key={card.publicId}

View File

@@ -0,0 +1,101 @@
import { authClient } from "@kan/auth/client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export function DeleteAccountConfirmation() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
const utils = api.useUtils();
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const deleteAccountMutation = useMutation({
mutationFn: () => authClient.deleteUser(),
onSuccess: async () => {
closeModal();
showPopup({
header: "Account deleted",
message: "Your account has been deleted.",
icon: "success",
});
utils.invalidate();
router.push("/");
},
onError: () => {
closeModal();
showPopup({
header: "Error deleting account",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const handleDeleteAccount = () => {
deleteAccountMutation.mutate();
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure you want to delete your account?`}
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent deletion of all data associated with
your account.
</p>
</div>
<div className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="acknowledgment"
name="acknowledgment"
type="checkbox"
aria-describedby="acknowledgment-description"
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
checked={isAcknowledgmentChecked}
onChange={() =>
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
}
/>
</div>
<div className="ml-3 text-sm leading-6">
<p
id="comments-description"
className="text-light-900 dark:text-dark-1000"
>
I acknowledge that all of my account data will be permanently
deleted and want to proceed.
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDeleteAccount}
disabled={!isAcknowledgmentChecked}
isLoading={deleteAccountMutation.isPending}
>
Delete account
</Button>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -6,11 +7,13 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import Avatar from "./components/Avatar";
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
@@ -21,9 +24,54 @@ export default function SettingsPage() {
const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const { showPopup } = usePopup();
const { data } = api.user.getUser.useQuery();
const {
data: integrations,
refetch: refetchIntegrations,
isLoading: integrationsLoading,
} = api.integration.providers.useQuery();
const { data: trelloUrl, refetch: refetchTrelloUrl } =
api.integration.getAuthorizationUrl.useQuery({ provider: "trello" }, {
enabled:
!integrationsLoading &&
!integrations?.some((integration) => integration.provider === "trello"),
refetchOnWindowFocus: true,
});
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } = api.integration.disconnect.useMutation({
onSuccess: () => {
refetchUser();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: "Trello disconnected",
message: "Your Trello account has been disconnected.",
icon: "success",
});
},
onError: () => {
showPopup({
header: "Error disconnecting Trello",
message: "An error occurred while disconnecting your Trello account.",
icon: "error",
});
},
});
const refetchUser = () => utils.user.getUser.refetch();
const handleOpenBillingPortal = async () => {
@@ -114,6 +162,48 @@ export default function SettingsPage() {
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Trello
</h2>
{!integrations?.some(
(integration) => integration.provider === "trello",
) && trelloUrl ? (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Connect your Trello account to import boards.
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={() =>
window.open(
trelloUrl.url,
"trello_auth",
"height=800,width=600",
)
}
>
Connect Trello
</Button>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
You are already connected to Trello.
</p>
<Button
variant="primary"
onClick={() => {
disconnectTrello({ provider: "trello" });
}}
>
Disconnect Trello
</Button>
</>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
API keys
@@ -127,20 +217,40 @@ export default function SettingsPage() {
/>
</div>
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
<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 workspace
</h2>
<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. Please
be certain.
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your workspace, there is no going back. This
action cannot be undone.
</p>
<Button
variant="primary"
onClick={() => openModal("DELETE_WORKSPACE")}
>
Delete workspace
</Button>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_WORKSPACE")}
>
Delete workspace
</Button>
</div>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete account
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your account, there is no going back. This
action cannot be undone.
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_ACCOUNT")}
>
Delete account
</Button>
</div>
</div>
</div>
@@ -152,6 +262,9 @@ export default function SettingsPage() {
{modalContentType === "UPDATE_WORKSPACE_URL" && (
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
)}
{modalContentType === "DELETE_ACCOUNT" && (
<DeleteAccountConfirmation />
)}
</Modal>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import baseConfig from "@kan/tailwind-config/web";
export default {
content: [...baseConfig.content],
plugins: [require("@tailwindcss/typography")],
presets: [baseConfig],
theme: {
extend: {

64
cloud/docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
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,30 +1,49 @@
version: "3.7"
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
ports:
- "3001:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
dockerfile: ./apps/web/Dockerfile.new
env_file:
- .env
command: ["pnpm", "start"]
environment:
- EMAIL_FROM=${EMAIL_FROM}
# Required environment variables
- 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_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
- EMAIL_FROM=${EMAIL_FROM}
# 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}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
@@ -62,16 +81,29 @@ services:
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
- 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_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
dokploy-network:
external: true
volumes:
kan_postgres_data:

View File

@@ -9,5 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
});

View File

@@ -2,6 +2,7 @@ import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
import: importRouter,
user: userRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -5,6 +5,7 @@ import * as boardRepo from "@kan/db/repository/board.repo";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.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 listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -13,10 +14,9 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { apiKeys, urls } from "./integration";
const TRELLO_API_URL = "https://api.trello.com/1";
interface TrelloBoard {
export interface TrelloBoard {
id: string;
name: string;
labels: TrelloLabel[];
@@ -42,10 +42,6 @@ interface TrelloCard {
labels: TrelloLabel[];
}
interface MemberData {
idBoards: string[];
}
export const importRouter = createTRPCRouter({
trello: createTRPCRouter({
getBoards: protectedProcedure
@@ -53,46 +49,51 @@ export const importRouter = createTRPCRouter({
openapi: {
summary: "Get boards from Trello",
method: "GET",
path: "/imports/trello/boards",
path: "/integrations/trello/boards",
description: "Retrieves all boards from Trello",
tags: ["Imports"],
tags: ["Integrations"],
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() })))
.query(async ({ input }) => {
const fetchMemberRes = await fetch(
`${TRELLO_API_URL}/tokens/${input.token}/member?key=${input.apiKey}`,
.query(async ({ ctx }) => {
const apiKey = apiKeys.trello;
if (!apiKey)
throw new TRPCError({
message: "Trello API key not found",
code: "INTERNAL_SERVER_ERROR",
});
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
"trello",
);
const member = (await fetchMemberRes.json()) as MemberData;
const token = integration?.accessToken;
const boardIds = member.idBoards;
if (!token)
throw new TRPCError({
message: "Trello token not found",
code: "UNAUTHORIZED",
});
const fetchBoard = async (boardId: string) => {
const response = await fetch(
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
);
const data = (await response.json()) as TrelloBoard;
const response = await fetch(
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
);
return data;
};
const data = (await response.json()) as TrelloBoard[];
const boards = [];
for (const boardId of boardIds) {
boards.push(Promise.resolve(fetchBoard(boardId)));
}
const boardDataArray = await Promise.all(boards);
return boardDataArray.map((board) => ({
return data.map((board) => ({
id: board.id,
name: board.name,
}));
@@ -102,7 +103,7 @@ export const importRouter = createTRPCRouter({
openapi: {
summary: "Import boards from Trello",
method: "POST",
path: "/imports/trello/import",
path: "/imports/trello/boards",
description: "Imports boards from Trello",
tags: ["Imports"],
protect: true,
@@ -111,8 +112,6 @@ export const importRouter = createTRPCRouter({
.input(
z.object({
boardIds: z.array(z.string()),
apiKey: z.string().length(32),
token: z.string().length(76),
workspacePublicId: z.string().min(12),
}),
)
@@ -120,12 +119,32 @@ export const importRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
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)
throw new TRPCError({
message: `User not authenticated`,
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(
ctx.db,
input.workspacePublicId,
@@ -150,7 +169,7 @@ export const importRouter = createTRPCRouter({
for (const boardId of input.boardIds) {
const response = await fetch(
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}&lists=open&cards=open&labels=all`,
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`,
);
const data = (await response.json()) as TrelloBoard;

View File

@@ -0,0 +1,128 @@
import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import { z } from "zod";
import * as integrationsRepo from "@kan/db/repository/integration.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const urls = {
trello: "https://api.trello.com/1",
};
export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY,
};
export const integrationRouter = createTRPCRouter({
providers: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integrations = await integrationsRepo.getProvidersForUser(
ctx.db,
user.id,
);
return integrations;
}),
disconnect: protectedProcedure
.meta({
openapi: {
summary: "Disconnect integration",
method: "POST",
path: "/integration/disconnect",
description: "Disconnects an integration",
tags: ["Integration"],
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.output(z.object({}))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
input.provider,
);
if (!integration)
throw new TRPCError({
message: "Integration not found",
code: "NOT_FOUND",
});
await integrationsRepo.deleteProviderForUser(
ctx.db,
user.id,
input.provider,
);
return {};
}),
getAuthorizationUrl: protectedProcedure
.meta({
openapi: {
summary: "Get authorization URL for an integration",
method: "GET",
path: "/integration/authorize",
description: "Retrieves the authorization URL for an integration",
tags: ["Integration"],
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.output(z.object({ url: z.string() }))
.query(async ({ ctx, input }) => {
const apiKey = apiKeys[input.provider];
if (!apiKey)
throw new TRPCError({
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} API key not set in environment variables`,
code: "INTERNAL_SERVER_ERROR",
});
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
input.provider,
);
if (integration)
throw new TRPCError({
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} integration already exists`,
code: "BAD_REQUEST",
});
if (input.provider === "trello") {
const url = `${urls[input.provider]}/authorize?key=${apiKey}&expiration=never&response_type=token&scope=read&return_url=${env("NEXT_PUBLIC_BASE_URL")}/settings/trello/authorize&callback_method=fragment`;
return { url };
}
throw new TRPCError({
message: "Invalid provider",
code: "BAD_REQUEST",
});
}),
});

View File

@@ -1,10 +1,11 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import { socialProviderList } from "better-auth/social-providers";
import { env } from "next-runtime-env";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
@@ -12,7 +13,6 @@ import * as userRepo from "@kan/db/repository/user.repo";
import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe";
import { socialProviderList } from "better-auth/social-providers";
export const configuredProviders = socialProviderList.reduce<
Record<
@@ -83,7 +83,8 @@ export const socialProvidersPlugin = () => ({
{
method: "GET",
},
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
async (ctx) =>
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
),
},
});
@@ -99,10 +100,13 @@ async function downloadImage(url: string): Promise<Buffer> {
export const initAuth = (db: dbClient) => {
return betterAuth({
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_BASE_URL!,
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? 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, {
provider: "pg",
schema: {
@@ -110,8 +114,22 @@ export const initAuth = (db: dbClient) => {
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,
user: {
deleteUser: {
enabled: true,
},
additionalFields: {
stripeCustomerId: {
type: "string",
@@ -148,8 +166,17 @@ export const initAuth = (db: dbClient) => {
databaseHooks: {
user: {
create: {
async after(user, _context) {
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
before() {
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
return Promise.resolve(false);
}
return Promise.resolve(true);
},
async after(user) {
if (
user.image &&
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
) {
try {
const client = new S3Client({
region: env("S3_REGION") ?? "",
@@ -162,18 +189,21 @@ export const initAuth = (db: dbClient) => {
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
const fileExtension =
user.image.split(".").pop()?.split("?")[0] || "jpg";
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
const imageBuffer = await downloadImage(user.image);
await client.send(new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: key,
Body: imageBuffer,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
ACL: 'public-read',
}));
await client.send(
new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: key,
Body: imageBuffer,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
ACL: "public-read",
}),
);
await userRepo.update(db, user.id, {
image: key,
});
@@ -181,9 +211,9 @@ export const initAuth = (db: dbClient) => {
console.error(error);
}
}
}
}
}
},
},
},
},
hooks: {
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;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
END $$;

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "integration" (
"provider" varchar(255) NOT NULL,
"userId" uuid NOT NULL,
"accessToken" varchar(255) NOT NULL,
"refreshToken" varchar(255),
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
);
--> statement-breakpoint
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,152 @@
ALTER TABLE "board" DROP CONSTRAINT "board_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "board" DROP CONSTRAINT "board_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card" DROP CONSTRAINT "card_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card" DROP CONSTRAINT "card_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "feedback" DROP CONSTRAINT "feedback_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "import" DROP CONSTRAINT "import_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "label" DROP CONSTRAINT "label_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "label" DROP CONSTRAINT "label_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "list" DROP CONSTRAINT "list_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "list" DROP CONSTRAINT "list_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_userId_user_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_deletedBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "board" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "card_activity" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "card" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "card_comments" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "feedback" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "import" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "label" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "list" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "workspace" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "import" ADD CONSTRAINT "import_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "integration" (
"provider" varchar(255) NOT NULL,
"userId" uuid NOT NULL,
"accessToken" varchar(255) NOT NULL,
"refreshToken" varchar(255),
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
);
--> statement-breakpoint
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

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,6 +29,27 @@
"when": 1749118315675,
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
"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

@@ -0,0 +1,59 @@
import { and, eq, gte } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { integrations } from "@kan/db/schema";
export const isProviderAvailableForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
const integration = await db.query.integrations.findFirst({
where: and(
eq(integrations.userId, userId),
eq(integrations.provider, provider),
gte(integrations.expiresAt, new Date()),
),
});
return !!integration;
};
export const getProviderForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
const integration = await db.query.integrations.findFirst({
where: and(
eq(integrations.userId, userId),
eq(integrations.provider, provider),
gte(integrations.expiresAt, new Date()),
),
});
return integration;
};
export const getProvidersForUser = async (db: dbClient, userId: string) => {
const integration = await db.query.integrations.findMany({
where: and(
eq(integrations.userId, userId),
gte(integrations.expiresAt, new Date()),
),
});
return integration;
};
export const deleteProviderForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
await db
.delete(integrations)
.where(
and(eq(integrations.userId, userId), eq(integrations.provider, provider)),
);
};

View File

@@ -81,5 +81,6 @@ export const apiKeyRelations = relations(apikey, ({ one }) => ({
user: one(users, {
fields: [apikey.userId],
references: [users.id],
relationName: "apiKeyUser",
}),
}));

View File

@@ -33,13 +33,15 @@ export const boards = pgTable(
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
importId: bigint("importId", { mode: "number" }).references(
() => imports.id,
),
@@ -60,19 +62,23 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
createdBy: one(users, {
fields: [boards.createdBy],
references: [users.id],
relationName: "boardCreatedByUser",
}),
lists: many(lists),
labels: many(labels),
deletedBy: one(users, {
fields: [boards.deletedBy],
references: [users.id],
relationName: "boardDeletedByUser",
}),
import: one(imports, {
fields: [boards.importId],
references: [imports.id],
relationName: "boardImport",
}),
workspace: one(workspaces, {
fields: [boards.workspaceId],
references: [workspaces.id],
relationName: "boardWorkspace",
}),
}));

View File

@@ -44,13 +44,15 @@ export const cards = pgTable("card", {
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
index: integer("index").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
listId: bigint("listId", { mode: "number" })
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
@@ -61,20 +63,24 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
createdBy: one(users, {
fields: [cards.createdBy],
references: [users.id],
relationName: "cardsCreatedByUser",
}),
list: one(lists, {
fields: [cards.listId],
references: [lists.id],
relationName: "cardsList",
}),
deletedBy: one(users, {
fields: [cards.deletedBy],
references: [users.id],
relationName: "cardsDeletedByUser",
}),
labels: many(cardsToLabels),
members: many(cardToWorkspaceMembers),
import: one(imports, {
fields: [cards.importId],
references: [imports.id],
relationName: "cardsImport",
}),
comments: many(comments),
activities: many(cardActivities),
@@ -90,23 +96,29 @@ export const cardActivities = pgTable("card_activity", {
fromIndex: integer("fromIndex"),
toIndex: integer("toIndex"),
fromListId: bigint("fromListId", { mode: "number" }).references(
() => lists.id, { onDelete: "cascade" },
() => lists.id,
{ onDelete: "cascade" },
),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, {
onDelete: "cascade",
}),
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, {
onDelete: "cascade",
}),
workspaceMemberId: bigint("workspaceMemberId", {
mode: "number",
}).references(() => workspaceMembers.id, { onDelete: "cascade" }),
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
fromTitle: varchar("fromTitle", { length: 255 }),
toTitle: varchar("toTitle", { length: 255 }),
fromDescription: text("fromDescription"),
toDescription: text("toDescription"),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id, { onDelete: "cascade" },
() => comments.id,
{ onDelete: "cascade" },
),
fromComment: text("fromComment"),
toComment: text("toComment"),
@@ -116,34 +128,42 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
card: one(cards, {
fields: [cardActivities.cardId],
references: [cards.id],
relationName: "cardActivitiesCard",
}),
fromList: one(lists, {
fields: [cardActivities.fromListId],
references: [lists.id],
relationName: "cardActivitiesFromList",
}),
toList: one(lists, {
fields: [cardActivities.toListId],
references: [lists.id],
relationName: "cardActivitiesToList",
}),
label: one(labels, {
fields: [cardActivities.labelId],
references: [labels.id],
relationName: "cardActivitiesLabel",
}),
workspaceMember: one(workspaceMembers, {
fields: [cardActivities.workspaceMemberId],
references: [workspaceMembers.id],
relationName: "cardActivitiesWorkspaceMember",
}),
user: one(users, {
fields: [cardActivities.createdBy],
references: [users.id],
relationName: "cardActivitiesUser",
}),
member: one(workspaceMembers, {
fields: [cardActivities.workspaceMemberId],
references: [workspaceMembers.id],
relationName: "cardActivitiesMember",
}),
comment: one(comments, {
fields: [cardActivities.commentId],
references: [comments.id],
relationName: "cardActivitiesComment",
}),
}));
@@ -164,10 +184,12 @@ export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
card: one(cards, {
fields: [cardsToLabels.cardId],
references: [cards.id],
relationName: "cardToLabelsCard",
}),
label: one(labels, {
fields: [cardsToLabels.labelId],
references: [labels.id],
relationName: "cardToLabelsLabel",
}),
}));
@@ -190,10 +212,12 @@ export const cardToWorkspaceMembersRelations = relations(
card: one(cards, {
fields: [cardToWorkspaceMembers.cardId],
references: [cards.id],
relationName: "cardToWorkspaceMembersCard",
}),
member: one(workspaceMembers, {
fields: [cardToWorkspaceMembers.workspaceMemberId],
references: [workspaceMembers.id],
relationName: "cardToWorkspaceMembersMember",
}),
}),
);
@@ -205,26 +229,31 @@ export const comments = pgTable("card_comments", {
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "cascade" }),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const commentsRelations = relations(comments, ({ one }) => ({
card: one(cards, {
fields: [comments.cardId],
references: [cards.id],
relationName: "commentsCard",
}),
createdBy: one(users, {
fields: [comments.createdBy],
references: [users.id],
relationName: "commentsCreatedByUser",
}),
deletedBy: one(users, {
fields: [comments.deletedBy],
references: [users.id],
relationName: "commentsDeletedByUser",
}),
}));

View File

@@ -13,9 +13,9 @@ import { users } from "./users";
export const feedback = pgTable("feedback", {
id: bigserial("id", { mode: "number" }).primaryKey(),
feedback: text("feedback").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
url: text("url").notNull(),
@@ -26,5 +26,6 @@ export const feedbackRelations = relations(feedback, ({ one }) => ({
createdBy: one(users, {
fields: [feedback.createdBy],
references: [users.id],
relationName: "feedbackCreatedByUser",
}),
}));

View File

@@ -26,9 +26,9 @@ export const imports = pgTable("import", {
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
source: importSourceEnum("source").notNull(),
status: importStatusEnum("status").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}).enableRLS();
@@ -36,6 +36,7 @@ export const importsRelations = relations(imports, ({ one, many }) => ({
createdBy: one(users, {
fields: [imports.createdBy],
references: [users.id],
relationName: "importsCreatedByUser",
}),
boards: many(boards),
cards: many(cards),

View File

@@ -7,4 +7,5 @@ export * from "./imports";
export * from "./labels";
export * from "./lists";
export * from "./users";
export * from "./integrations";
export * from "./workspaces";

View File

@@ -0,0 +1,40 @@
import { relations } from "drizzle-orm";
import {
pgTable,
primaryKey,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const integrations = pgTable(
"integration",
{
provider: varchar("provider", { length: 255 }).notNull(),
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: varchar("accessToken", { length: 255 }).notNull(),
refreshToken: varchar("refreshToken", { length: 255 }),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt")
.$defaultFn(() => new Date())
.notNull(),
updatedAt: timestamp("updatedAt").$onUpdateFn(() => new Date()),
},
(table) => [
primaryKey({
name: "integration_pkey",
columns: [table.userId, table.provider],
}),
],
).enableRLS();
export const integrationsRelations = relations(integrations, ({ one }) => ({
user: one(users, {
fields: [integrations.userId],
references: [users.id],
}),
}));

View File

@@ -18,9 +18,9 @@ export const labels = pgTable("label", {
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
colourCode: varchar("colourCode", { length: 12 }),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
boardId: bigint("boardId", { mode: "number" })
@@ -28,17 +28,21 @@ export const labels = pgTable("label", {
.references(() => boards.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const labelsRelations = relations(labels, ({ one, many }) => ({
createdBy: one(users, {
fields: [labels.createdBy],
references: [users.id],
relationName: "labelsCreatedByUser",
}),
deletedBy: one(users, {
fields: [labels.deletedBy],
references: [users.id],
relationName: "labelsDeletedByUser",
}),
board: one(boards, {
fields: [labels.boardId],
@@ -48,5 +52,6 @@ export const labelsRelations = relations(labels, ({ one, many }) => ({
import: one(imports, {
fields: [labels.importId],
references: [imports.id],
relationName: "labelsImport",
}),
}));

View File

@@ -19,13 +19,15 @@ export const lists = pgTable("list", {
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
@@ -36,18 +38,22 @@ export const listsRelations = relations(lists, ({ one, many }) => ({
createdBy: one(users, {
fields: [lists.createdBy],
references: [users.id],
relationName: "listsCreatedByUser",
}),
board: one(boards, {
fields: [lists.boardId],
references: [boards.id],
relationName: "listsBoard",
}),
cards: many(cards),
deletedBy: one(users, {
fields: [lists.deletedBy],
references: [users.id],
relationName: "listsDeletedByUser",
}),
import: one(imports, {
fields: [lists.importId],
references: [imports.id],
relationName: "listsImport",
}),
}));

View File

@@ -13,6 +13,7 @@ import { cards } from "./cards";
import { imports } from "./imports";
import { lists } from "./lists";
import { workspaceMembers, workspaces } from "./workspaces";
import { integrations } from "./integrations";
export const users = pgTable("user", {
id: uuid("id")
@@ -29,12 +30,33 @@ export const users = pgTable("user", {
}).enableRLS();
export const usersRelations = relations(users, ({ many }) => ({
boards: many(boards),
cards: many(cards),
deletedBoards: many(boards, {
relationName: "boardDeletedByUser",
}),
boards: many(boards, {
relationName: "boardCreatedByUser",
}),
deletedCards: many(cards, {
relationName: "cardsDeletedByUser",
}),
cards: many(cards, {
relationName: "cardsCreatedByUser",
}),
imports: many(imports),
lists: many(lists),
workspaces: many(workspaces),
deletedLists: many(lists, {
relationName: "listsDeletedByUser",
}),
lists: many(lists, {
relationName: "listsCreatedByUser",
}),
deletedWorkspaces: many(workspaces, {
relationName: "workspaceDeletedByUser",
}),
workspaces: many(workspaces, {
relationName: "workspaceCreatedByUser",
}),
apiKeys: many(apikey),
integrations: many(integrations),
}));
export const usersToWorkspacesRelations = relations(
@@ -43,18 +65,22 @@ export const usersToWorkspacesRelations = relations(
addedBy: one(users, {
fields: [workspaceMembers.createdBy],
references: [users.id],
relationName: "usersToWorkspacesAddedByUser",
}),
deletedBy: one(users, {
fields: [workspaceMembers.deletedBy],
references: [users.id],
relationName: "usersToWorkspacesDeletedByUser",
}),
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
relationName: "usersToWorkspacesUser",
}),
workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId],
references: [workspaces.id],
relationName: "usersToWorkspacesWorkspace",
}),
}),
);

View File

@@ -36,17 +36,28 @@ export const workspaces = pgTable("workspace", {
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull().unique(),
plan: workspacePlanEnum("plan").notNull().default("free"),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
user: one(users, {
fields: [workspaces.createdBy],
references: [users.id],
relationName: "workspaceCreatedByUser",
}),
deletedBy: one(users, {
fields: [workspaces.deletedBy],
references: [users.id],
relationName: "workspaceDeletedByUser",
}),
members: many(workspaceMembers),
boards: many(boards),
}));
@@ -55,7 +66,7 @@ export const workspaceMembers = pgTable("workspace_members", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
email: varchar("email", { length: 255 }).notNull(),
userId: uuid("userId").references(() => users.id),
userId: uuid("userId").references(() => users.id, { onDelete: "set null" }),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
@@ -63,7 +74,9 @@ export const workspaceMembers = pgTable("workspace_members", {
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
role: memberRoleEnum("role").notNull(),
status: memberStatusEnum("status").default("invited").notNull(),
}).enableRLS();
@@ -74,10 +87,12 @@ export const workspaceMembersRelations = relations(
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
relationName: "workspaceMembersUser",
}),
workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId],
references: [workspaces.id],
relationName: "workspaceMembersWorkspace",
}),
}),
);

View File

@@ -3,22 +3,27 @@ import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link";
import ResetPasswordTemplate from "./templates/reset-password";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate,
RESET_PASSWORD: ResetPasswordTemplate,
};
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
secure: process.env.SMTP_SECURE !== "false",
...(process.env.SMTP_USER &&
process.env.SMTP_PASSWORD && {
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
}),
});
export const sendEmail = async (
@@ -27,22 +32,34 @@ export const sendEmail = async (
template: Templates,
data: Record<string, string>,
) => {
const EmailTemplate = emailTemplates[template];
try {
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const options = {
from: process.env.EMAIL_FROM,
to,
subject,
html,
};
const options = {
from: process.env.EMAIL_FROM,
to,
subject,
html,
};
const response = await transporter.sendMail(options);
const response = await transporter.sendMail(options);
if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`);
if (!response.accepted.length) {
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

@@ -0,0 +1,108 @@
import { Body } from "@react-email/body";
import { Button } from "@react-email/button";
import { Container } from "@react-email/container";
import { Head } from "@react-email/head";
import { Heading } from "@react-email/heading";
import { Hr } from "@react-email/hr";
import { Html } from "@react-email/html";
import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview";
import { Text } from "@react-email/text";
import { env } from "next-runtime-env";
export const ResetPasswordTemplate = ({
resetPasswordUrl,
resetPasswordToken,
}: {
resetPasswordUrl?: string;
resetPasswordToken?: string;
}) => (
<Html>
<Head />
<Preview>Reset your Kan password</Preview>
<Body style={{ backgroundColor: "white" }}>
<Container
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
margin: "auto",
paddingLeft: "0.75rem",
paddingRight: "0.75rem",
}}
>
<Heading
style={{
marginTop: "2.5rem",
marginBottom: "2.5rem",
fontSize: "24px",
fontWeight: "bold",
color: "#232323",
}}
>
kan.bn
</Heading>
<Heading
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
>
Reset your Kan password
</Heading>
<Text
style={{
fontSize: "0.875rem",
marginBottom: "2rem",
color: "#232323",
}}
>
Click the button below to reset your password.
</Text>
<Button
target="_blank"
href={resetPasswordUrl}
style={{
marginBottom: "2rem",
borderRadius: "0.375rem",
backgroundColor: "#282828",
paddingLeft: "1.5rem",
paddingRight: "1.5rem",
paddingTop: "1rem",
paddingBottom: "1rem",
fontSize: "0.875rem",
fontWeight: "500",
lineHeight: "1",
color: "white",
}}
>
Reset your password
</Button>
<Text
style={{
marginBottom: "1rem",
fontSize: "0.875rem",
color: "#7e7e7e",
}}
>
If you didn&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;

1233
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,49 +3,28 @@
"ui": "tui",
"tasks": {
"topo": {
"dependsOn": [
"^topo"
]
"dependsOn": ["^topo"]
},
"build": {
"dependsOn": [
"^build"
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
"dependsOn": ["^build"],
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
},
"dev": {
"dependsOn": [
"^dev"
],
"dependsOn": ["^dev"],
"cache": false,
"persistent": false
},
"format": {
"outputs": [
".cache/.prettiercache"
],
"outputs": [".cache/.prettiercache"],
"outputLogs": "new-only"
},
"lint": {
"dependsOn": [
"^topo",
"^build"
],
"outputs": [
".cache/.eslintcache"
]
"dependsOn": ["^topo", "^build"],
"outputs": [".cache/.eslintcache"]
},
"typecheck": {
"dependsOn": [
"^topo",
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
"dependsOn": ["^topo", "^build"],
"outputs": [".cache/tsbuildinfo.json"]
},
"clean": {
"cache": false
@@ -68,6 +47,8 @@
},
"globalEnv": [
"POSTGRES_URL",
"TRELLO_APP_API_KEY",
"TRELLO_APP_SECRET",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"DISCORD_CLIENT_ID",
@@ -110,6 +91,7 @@
"SMTP_PORT",
"SMTP_USER",
"SMTP_PASSWORD",
"SMTP_SECURE",
"NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
@@ -117,13 +99,15 @@
"NEXT_PUBLIC_STORAGE_DOMAIN",
"NEXT_PUBLIC_STORAGE_URL",
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
"NEXT_PUBLIC_DISABLE_SIGN_UP",
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
"S3_REGION",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"S3_ENDPOINT",
"PORT",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"BETTER_AUTH_TRUSTED_ORIGINS"
],
"globalPassThroughEnv": [
@@ -134,4 +118,4 @@
"VERCEL_URL",
"npm_lifecycle_event"
]
}
}