Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
808d23bd15 fix: add google to the remote patterns 2025-06-05 12:58:01 +01:00
Lonneke Vuur
1b2389d618 fix: issue 23, handle external URLs in avatar rendering 2025-06-05 10:28:26 +02:00
73 changed files with 544 additions and 12097 deletions

View File

@@ -1,74 +1,23 @@
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-) POSTGRES_URL=
# Required environment variables EMAIL_FROM=
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# Only required if deploying from compose
POSTGRES_PASSWORD=
# SMTP (optional)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=465 # Port 587 will not work SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
# S3 storage (optional) NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
S3_REGION= S3_REGION=
S3_ENDPOINT= S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
# Auth config (optional) BETTER_AUTH_SECRET=
NEXT_PUBLIC_ALLOW_CREDENTIALS= BETTER_AUTH_URL=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS= BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_ISSUER=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
KICK_CLIENT_ID=
KICK_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
VK_CLIENT_ID=
VK_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
ROBLOX_CLIENT_ID=
ROBLOX_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
TIKTOK_CLIENT_ID=
TIKTOK_CLIENT_SECRET=
TIKTOK_CLIENT_KEY=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

View File

@@ -66,13 +66,6 @@ jobs:
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
@@ -83,7 +76,6 @@ jobs:
context: . context: .
file: apps/web/Dockerfile file: apps/web/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha

3
.gitignore vendored
View File

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

119
README.md
View File

@@ -45,70 +45,6 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn) - [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [React Email](https://react.email/?ref=kan.bn) - [React Email](https://react.email/?ref=kan.bn)
## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
postgres_data:
```
2. Start the containers in detached mode:
```bash
docker compose up -d
```
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 🧑‍💻 ## Local Development 🧑‍💻
1. Clone the repository (or fork) 1. Clone the repository (or fork)
@@ -138,34 +74,27 @@ pnpm dev
## Environment Variables 🔐 ## Environment Variables 🔐
| Variable | Description | Required | Example | | Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- | | -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` | | `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` | | `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | | `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` | | `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | For Email | `resend` | | `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` | | `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` | | `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string | | `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` | | `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` | | `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` | | `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | | `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` | | `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | | `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `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. See `.env.example` for a complete list of supported environment variables.
@@ -173,12 +102,6 @@ See `.env.example` for a complete list of supported environment variables.
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
## Contributors 👥
<a href="https://github.com/kanbn/kan/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## License 📝 ## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE). Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -3,25 +3,31 @@ title: Trello
description: "Import your Trello boards to Kan." description: "Import your Trello boards to Kan."
--- ---
## Authorizing Kan.bn for Trello ## Getting your Trello API key and token
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes): Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
1. **Authorize Kan.bn for Trello** 1. **Sign in to your Trello account**
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**. Go to [https://trello.com](https://trello.com) and log in with your Trello account.
2. **Log in to your Trello account** 2. **Create a new integration**
You will be redirected to Trello to log in to your Trello account. Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
3. **Authorize Kan.bn for Trello** - **Name**: Choose a name for your integration (e.g. "Kan Import")
Approve the permissions and you will be redirected back to Kan.bn. - **Workspace**: Select the workspace containing the boards you want to import
- **Email**: Enter your email address
- **Support email**: Enter your email address
- **Author**: Add your name
4. **Verify the connection** 3. **Get a Trello API Key**
You should see a **Disconnect Trello** button. Now click **Generate a new API key** to create an API key.
4. **Generate a Trello Token**
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
## Importing your Trello boards ## Importing your Trello boards
Once you have authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps: Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
1. **Create a new import in Kan** 1. **Create a new import in Kan**
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**. Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
@@ -29,5 +35,8 @@ Once you have authorized Kan.bn for Trello, importing your Trello boards is easy
2. **Select Trello as the Import Source** 2. **Select Trello as the Import Source**
Choose **Trello** from the list of import options and click **Select source**. Choose **Trello** from the list of import options and click **Select source**.
3. **Start the Import** 3. **Enter Your Trello API Key and Token**
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
4. **Start the Import**
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan. Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.

View File

@@ -25,11 +25,7 @@ Get started with Kan by choosing your preferred deployment option below.
Get started instantly with our hosted solution - we'll handle all the Get started instantly with our hosted solution - we'll handle all the
hosting, scaling, and maintenance for you. hosting, scaling, and maintenance for you.
</Card> </Card>
<Card <Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
title="Self-host"
icon="code"
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
>
For full data ownership and control, you can self-host Kan on your For full data ownership and control, you can self-host Kan on your
infrastructure. infrastructure.
</Card> </Card>

View File

@@ -31,7 +31,7 @@ COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace. # Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" } # Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker RUN turbo prune --scope=${PROJECT} --docker
# 3. Build the project # 3. Build the project
FROM base AS builder FROM base AS builder
@@ -53,14 +53,8 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
# Copy source code of isolated subworkspace # Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT} RUN pnpm build --filter=${PROJECT}
# Copy static files to standalone directory
RUN mkdir -p apps/web/.next/standalone/.next && \
mv apps/web/public apps/web/.next/standalone/ && \
mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application # 4. Final image - runner stage to run the application
FROM base AS runner FROM base AS runner
ARG APP_DIRNAME ARG APP_DIRNAME
@@ -81,5 +75,5 @@ ARG PORT=3000
ENV PORT=${PORT} ENV PORT=${PORT}
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["/bin/sh", "./entrypoint.sh"] CMD ["pnpm", "start"]

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# Run migrations from the Turborepo root context
echo "Running database migrations..."
# Navigate to the Turborepo root to run the db:migrate command
pnpm --prefix /app db:migrate
# Check if migration was successful
if [ $? -ne 0 ]; then
echo "\nDatabase migration failed! Exiting."
exit 1
fi
echo "\nStarting Next.js application..."
# Start the Next.js application from the web app's directory
exec node .next/standalone/apps/web/server.js

View File

@@ -10,7 +10,6 @@ configureRuntimeEnv();
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: "standalone",
reactStrictMode: true, reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */ /** Enables hot reloading for local packages without a build step */
@@ -32,10 +31,6 @@ const config = {
protocol: "https", protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
}, },
{
protocol: "http",
hostname: "localhost",
},
{ {
protocol: "https", protocol: "https",
hostname: "*.googleusercontent.com", hostname: "*.googleusercontent.com",

View File

@@ -23,13 +23,7 @@
"@kan/db": "workspace:^", "@kan/db": "workspace:^",
"@kan/shared": "workspace:^", "@kan/shared": "workspace:^",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/next": "^11.0.0-rc.660", "@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
@@ -37,6 +31,8 @@
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"geist": "^1.3.1", "geist": "^1.3.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "^14.2.15", "next": "^14.2.15",
"next-logger": "^5.0.1", "next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2", "next-runtime-env": "^1.7.2",
@@ -47,12 +43,10 @@
"react-contenteditable": "^3.3.7", "react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18", "react-dom": "catalog:react18",
"react-hook-form": "^7.51.1", "react-hook-form": "^7.51.1",
"react-icons": "^5.5.0", "react-icons": "^4.12.0",
"react-lottie-player": "^1.5.5", "react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1", "superjson": "2.2.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
@@ -61,6 +55,7 @@
"@kan/stripe": "workspace:*", "@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7", "@types/node": "^20.17.7",
"@types/react": "catalog:react18", "@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7", "@types/react-beautiful-dnd": "^13.1.7",

1
apps/web/public/__ENV.js Normal file
View File

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

View File

@@ -1,338 +1,115 @@
import type { SocialProvider } from "better-auth/social-providers";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import { FaGoogle } from "react-icons/fa";
FaApple,
FaDiscord,
FaDropbox,
FaFacebook,
FaGithub,
FaGitlab,
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaReddit,
FaSpotify,
FaTiktok,
FaTwitch,
FaTwitter,
FaVk,
} from "react-icons/fa";
import { SiRoblox, SiZoom } from "react-icons/si";
import { TbBrandKick } from "react-icons/tb";
import { z } from "zod"; import { z } from "zod";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
interface FormValues { interface FormValues {
name?: string;
email: string; email: string;
password?: string;
} }
interface AuthProps { interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void; setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
} }
const EmailSchema = z.object({ const EmailSchema = z.object({ email: z.string().email() });
name: z.string().optional(),
email: z.string().email(),
password: z.string().optional(),
});
const availableSocialProviders = { export function Auth({ setIsMagicLinkSent }: AuthProps) {
google: { const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
id: "google", useState(false);
name: "Google",
icon: FaGoogle,
},
github: {
id: "github",
name: "GitHub",
icon: FaGithub,
},
discord: {
id: "discord",
name: "Discord",
icon: FaDiscord,
},
apple: {
id: "apple",
name: "Apple",
icon: FaApple,
},
microsoft: {
id: "microsoft",
name: "Microsoft",
icon: FaMicrosoft,
},
facebook: {
id: "facebook",
name: "Facebook",
icon: FaFacebook,
},
spotify: {
id: "spotify",
name: "Spotify",
icon: FaSpotify,
},
twitch: {
id: "twitch",
name: "Twitch",
icon: FaTwitch,
},
twitter: {
id: "twitter",
name: "Twitter",
icon: FaTwitter,
},
dropbox: {
id: "dropbox",
name: "Dropbox",
icon: FaDropbox,
},
linkedin: {
id: "linkedin",
name: "LinkedIn",
icon: FaLinkedin,
},
gitlab: {
id: "gitlab",
name: "GitLab",
icon: FaGitlab,
},
tiktok: {
id: "tiktok",
name: "TikTok",
icon: FaTiktok,
},
reddit: {
id: "reddit",
name: "Reddit",
icon: FaReddit,
},
roblox: {
id: "roblox",
name: "Roblox",
icon: SiRoblox,
},
vk: {
id: "vk",
name: "VK",
icon: FaVk,
},
kick: {
id: "kick",
name: "Kick",
icon: TbBrandKick,
},
zoom: {
id: "zoom",
name: "Zoom",
icon: SiZoom,
},
};
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 [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
watch,
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(EmailSchema), resolver: zodResolver(EmailSchema),
}); });
const { data: socialProviders } = useQuery({ const handleLoginWithEmail = async (email: string) => {
queryKey: ["social_providers"],
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (
email: string,
password?: string,
name?: string,
) => {
setIsLoginWithEmailPending(true); setIsLoginWithEmailPending(true);
setLoginError(null); setLoginError(null);
if (password) { const { error } = await authClient.signIn.magicLink({
if (isSignUp && name) { email,
await authClient.signUp.email(
{
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);
};
const handleLoginWithProvider = async (provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider,
callbackURL: "/boards", callbackURL: "/boards",
}); });
setIsLoginWithProviderPending(null); setIsLoginWithEmailPending(false);
if (error) { if (error) {
setLoginError( setLoginError(
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`, "Something went wrong, please try again later or contact customer support.",
); );
} else {
setIsMagicLinkSent(true, email);
}
};
const handleLoginWithGoogle = async () => {
setIsLoginWithGooglePending(true);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider: "google",
callbackURL: "/boards",
});
setIsLoginWithGooglePending(false);
if (error) {
setLoginError("Failed to login with Google. Please try again.");
} }
}; };
const onSubmit = async (values: FormValues) => { const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email, values.password, values.name); await handleLoginWithEmail(values.email);
}; };
const password = watch("password");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{socialProviders?.length !== 0 && ( <div>
<div className="space-y-2"> <Button
{Object.entries(availableSocialProviders).map(([key, provider]) => { onClick={handleLoginWithGoogle}
if (!socialProviders?.includes(key)) { isLoading={isLoginWithGooglePending}
return null; iconLeft={<FaGoogle />}
} fullWidth
return ( size="lg"
<Button >
onClick={() => handleLoginWithProvider(key as SocialProvider)} Continue with Google
isLoading={isLoginWithProviderPending === key} </Button>
iconLeft={<provider.icon />} </div>
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
);
})}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && ( <div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> <span className="text-sm text-light-900 dark:text-dark-900">or</span>
<span className="text-sm text-light-900 dark:text-dark-900"> <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
or
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<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>
<div className="mt-[1.5rem] flex items-center gap-4"> <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]">
<Button <Button
isLoading={isLoginWithEmailPending} isLoading={isLoginWithEmailPending}
fullWidth fullWidth
size="lg" size="lg"
variant="secondary" variant="secondary"
> >
{isSignUp ? "Sign up with " : "Continue with "} Continue with email
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -1,419 +0,0 @@
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
SuggestionOptions,
} from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react";
import Placeholder from "@tiptap/extension-placeholder";
import {
BubbleMenu,
EditorContent,
Extension,
ReactRenderer,
useEditor,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import {
HiH1,
HiH2,
HiH3,
HiOutlineBold,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineCodeBracket,
HiOutlineCodeBracketSquare,
HiOutlineItalic,
HiOutlineListBullet,
HiOutlineNumberedList,
HiOutlineStrikethrough,
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import tippy from "tippy.js";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
slashSuggestion: {
setSlashSuggestion: () => ReturnType;
};
}
}
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
disabled?: boolean;
}
export interface SlashCommandsOptions {
suggestion?: Partial<SuggestionOptions>;
commandItems?: SlashCommandItem[];
options?: any;
}
function filterSlashCommandItems(items: SlashCommandItem[], query: string) {
return items.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
}
export interface RenderSuggestionsProps {
editor: TiptapEditor;
clientRect: () => DOMRect;
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}
const CommandsList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (event.key === "Enter") {
const item = items[selectedIndex];
if (item) {
command(item);
}
return true;
}
return false;
},
}));
return (
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="max-h-[350px] overflow-y-auto p-1">
{items.map((item, index) => (
<button
key={item.title}
onClick={() => command(item)}
className={twMerge(
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
)}
>
<span className="text-dark-700 dark:text-dark-800">
{item.icon}
</span>
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
{item.title}
</span>
</button>
))}
</div>
</div>
);
});
CommandsList.displayName = "CommandsList";
const RenderSuggestions = () => {
let reactRenderer: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: RenderSuggestionsProps) => {
reactRenderer = new ReactRenderer(CommandsList, {
props,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: RenderSuggestionsProps) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: SuggestionKeyDownProps): boolean {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return (
(
reactRenderer.ref as {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
).onKeyDown?.(props) ?? false
);
},
onExit() {
popup[0]?.destroy();
reactRenderer.destroy();
},
};
};
const SlashCommands = Extension.create<SlashCommandsOptions>({
name: "slash-commands",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }) => {
editor.chain().focus().deleteRange(range).run();
props.command({ editor, range });
},
items: ({ query }: { query: string }) => {
return filterSlashCommandItems(
this.parent().commandItems ?? [],
query,
);
},
render: () => {
let component: ReturnType<typeof RenderSuggestions>;
return {
onStart: (props: any) => {
component = RenderSuggestions();
component.onStart(props);
},
onUpdate(props: any) {
component.onUpdate(props);
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
return true;
}
return component.onKeyDown(props) ?? false;
},
onExit: () => {
component.onExit();
},
};
},
},
commandItems: [] as SlashCommandItem[],
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
render: RenderSuggestions,
} as SuggestionOptions),
];
},
});
export interface SlashNodeAttrs {
id: string | null;
label?: string | null;
}
const CommandItems: SlashCommandItem[] = [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
icon: <HiOutlineChatBubbleLeftEllipsis />,
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
export default function Editor({
content,
onChange,
onBlur,
readOnly = false,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor(
{
extensions: [
StarterKit,
Placeholder.configure({
placeholder: readOnly
? ""
: "Add description... (type '/' to open commands)",
}),
SlashCommands.configure({
commandItems: CommandItems,
suggestion: {
items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query),
startOfLine: true,
char: "/",
},
}),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
onBlur: ({ event }) => {
if (
document
.querySelector(".tippy-box")
?.contains(event.relatedTarget as Node)
)
return;
// Only trigger onBlur if the click was outside both the editor and menu
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
onBlur?.();
}
},
editorProps: {
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
editable: !readOnly,
injectCSS: false,
},
[content],
);
return (
<div ref={containerRef}>
<style jsx global>{`
.tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap p {
margin: 0 0 1rem 0 !important;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
editor={editor}
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
/>
</div>
);
}
function EditorBubbleMenu({ editor }: { editor: TiptapEditor | null }) {
const isMac = navigator.platform.includes("Mac");
const bubbleMenuItems = [
{
title: "Bold",
icon: <HiOutlineBold />,
keys: ["meta", "b"],
onClick: () => editor?.chain().focus().toggleBold().run(),
active: editor?.isActive("bold"),
},
{
title: "Italic",
icon: <HiOutlineItalic />,
keys: ["meta", "i"],
onClick: () => editor?.chain().focus().toggleItalic().run(),
active: editor?.isActive("italic"),
},
{
title: "Strikethrough",
icon: <HiOutlineStrikethrough />,
keys: ["meta", "shift", "s"],
onClick: () => editor?.chain().focus().toggleStrike().run(),
active: editor?.isActive("strike"),
},
{
title: "Code",
icon: <HiOutlineCodeBracket />,
keys: ["meta", "e"],
onClick: () => editor?.chain().focus().toggleCode().run(),
active: editor?.isActive("code"),
},
];
return (
<BubbleMenu editor={editor}>
<div className="flex items-center gap-2 rounded-md border border-light-600 bg-light-50 p-1 dark:border-dark-600 dark:bg-dark-50">
{bubbleMenuItems.map((item) => (
<Button
key={item.title}
className={twMerge(
"rounded p-1 text-light-900 focus:ring-2 focus:ring-light-600 dark:text-dark-900 dark:focus:ring-dark-600",
item.active && "bg-light-100 dark:bg-dark-400",
)}
title={`${item.title} [${item.keys.join(" + ").replace("meta", isMac ? "⌘" : "ctrl")}]`}
onClick={item.onClick}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.onClick();
}
}}
>
{item.icon}
</Button>
))}
</div>
</BubbleMenu>
);
}

View File

@@ -67,7 +67,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
className={twMerge( className={twMerge(
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6", "block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
prefix && "rounded-l-none", prefix && "rounded-l-none",
type === "password" && "pr-8",
className && className, className && className,
)} )}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -76,7 +75,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{type === "password" && ( {type === "password" && (
<button <button
type="button" type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900" className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
tabIndex={-1} tabIndex={-1}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
> >

View File

@@ -15,61 +15,19 @@ export const env = createEnv({
*/ */
server: { server: {
BETTER_AUTH_SECRET: z.string(), BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z BETTER_AUTH_URL: z.string(),
.string() BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) =>
!s ||
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
POSTGRES_URL: z.string().url(), POSTGRES_URL: z.string().url(),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITLAB_CLIENT_ID: z.string().optional(),
GITLAB_CLIENT_SECRET: z.string().optional(),
GITLAB_ISSUER: z.string().optional(),
MICROSOFT_CLIENT_ID: z.string().optional(),
MICROSOFT_CLIENT_SECRET: z.string().optional(),
TWITTER_CLIENT_ID: z.string().optional(),
TWITTER_CLIENT_SECRET: z.string().optional(),
KICK_CLIENT_ID: z.string().optional(),
KICK_CLIENT_SECRET: z.string().optional(),
ZOOM_CLIENT_ID: z.string().optional(),
ZOOM_CLIENT_SECRET: z.string().optional(),
DROPBOX_CLIENT_ID: z.string().optional(),
DROPBOX_CLIENT_SECRET: z.string().optional(),
VK_CLIENT_ID: z.string().optional(),
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(),
ROBLOX_CLIENT_SECRET: z.string().optional(),
SPOTIFY_CLIENT_ID: z.string().optional(),
SPOTIFY_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_ID: z.string().optional(),
TIKTOK_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_KEY: z.string().optional(),
TWITCH_CLIENT_ID: z.string().optional(),
TWITCH_CLIENT_SECRET: z.string().optional(),
APPLE_CLIENT_ID: z.string().optional(),
APPLE_CLIENT_SECRET: z.string().optional(),
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(), S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(), S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(), S3_ENDPOINT: z.string().optional(),
EMAIL_FROM: z.string().optional(), EMAIL_FROM: z.string(),
EMAIL_URL: z.string(),
EMAIL_TOKEN: z.string(),
}, },
/** /**
@@ -79,24 +37,10 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(), NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(), NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(), NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(), NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(), NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(), NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_DISABLE_SIGN_UP: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
}, },
/** /**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away. * Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -109,8 +53,6 @@ export const env = createEnv({
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL, NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME, NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN, NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
}, },
skipValidation: skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint", !!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

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

View File

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

View File

@@ -1,11 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env as nextRuntimeEnv } from "next-runtime-env"; import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"]; const allowedContentTypes = ["image/jpeg", "image/png"];
@@ -56,7 +54,7 @@ export default async function handler(
// @ts-ignore // @ts-ignore
client, client,
new PutObjectCommand({ new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename, Key: filename,
}), }),
); );

View File

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

View File

@@ -1,17 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useState } from "react"; import { useState } from "react";
import { authClient } from "@kan/auth/client"; // import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm"; import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
// import { api } from "~/utils/api";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); // const router = useRouter();
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false); const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>(""); const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -20,9 +18,15 @@ export default function LoginPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
const { data } = authClient.useSession(); // const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
if (data?.user.id) router.push("/boards"); // const { data } = api.user.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return ( return (
<> <>
@@ -51,14 +55,12 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{!isSignUpDisabled && ( <p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000"> Don't have an account?{" "}
Don't have an account?{" "} <span className="underline">
<span className="underline"> <Link href="/signup">Sign up</Link>
<Link href="/signup">Sign up</Link> </span>
</span> </p>
</p>
)}
</div> </div>
<PatternedBackground /> <PatternedBackground />
</div> </div>

View File

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

View File

@@ -1,16 +1,17 @@
import type { ReactNode } from "react"; import { type ReactNode } from "react";
import {
HiOutlinePlusSmall,
HiEllipsisHorizontal,
HiOutlineTrash,
HiOutlineSquaresPlus,
} from "react-icons/hi2";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import {
HiEllipsisHorizontal, import { api } from "~/utils/api";
HiOutlinePlusSmall, import { useModal } from "~/providers/modal";
HiOutlineSquaresPlus,
HiOutlineTrash,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
interface ListProps { interface ListProps {
children: ReactNode; children: ReactNode;
@@ -79,20 +80,20 @@ export default function List({
{...provided.dragHandleProps} {...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100" className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
> >
<div className="mb-2 flex justify-between"> <div className="flex justify-between">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="w-full focus-visible:outline-none" className="focus-visible:outline-none"
> >
<input <input
id="name" id="name"
type="text" type="text"
{...register("name")} {...register("name")}
onBlur={handleSubmit(onSubmit)} onBlur={handleSubmit(onSubmit)}
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000" className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
/> />
</form> </form>
<div className="flex items-center"> <div>
<button <button
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200" className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)} onClick={() => openNewCardForm(list.publicId)}

View File

@@ -12,7 +12,6 @@ import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown"; import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor from "~/components/Editor";
import Input from "~/components/Input"; import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle"; import Toggle from "~/components/Toggle";
@@ -64,7 +63,6 @@ export function NewCardForm({
const memberPublicIds = watch("memberPublicIds") || []; const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position"); const position = watch("position");
const title = watch("title");
const { data: boardData } = api.board.byId.useQuery(queryParams, { const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId, enabled: !!boardPublicId,
@@ -112,29 +110,16 @@ export function NewCardForm({
return { previousState: currentState }; return { previousState: currentState };
}, },
onError: (error, _newList, context) => { onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState); utils.board.byId.setData(queryParams, context?.previousState);
showPopup({ showPopup({
header: "Unable to create card", header: "Unable to create card",
message: error.data?.zodError?.fieldErrors.title?.[0] message: "Please try again later, or contact customer support.",
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
: "Please try again later, or contact customer support.",
icon: "error", icon: "error",
}); });
}, },
onSuccess: async () => { onSettled: async () => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
await utils.board.byId.invalidate(queryParams); await utils.board.byId.invalidate(queryParams);
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
}, },
}); });
@@ -179,6 +164,18 @@ export function NewCardForm({
})) ?? []; })) ?? [];
const onSubmit = (data: NewCardInput) => { const onSubmit = (data: NewCardInput) => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
createCard.mutate({ createCard.mutate({
title: data.title, title: data.title,
description: data.description, description: data.description,
@@ -254,12 +251,18 @@ export function NewCardForm({
/> />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6"> <Input
<Editor placeholder="Add description..."
content="" onChange={(e) => setValue("description", e.target.value)}
onChange={(value) => setValue("description", value)} value={watch("description")}
/> contentEditable
</div> onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div> </div>
<div className="mt-2 flex space-x-1"> <div className="mt-2 flex space-x-1">
<div className="w-fit"> <div className="w-fit">
@@ -384,12 +387,7 @@ export function NewCardForm({
/> />
<div> <div>
<Button <Button type="submit">Create card</Button>
type="submit"
disabled={title.length === 0 || createCard.isPending}
>
Create card
</Button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,13 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { env } from "next-runtime-env";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiCheck, HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { z } from "zod"; import { z } from "zod";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -51,7 +49,6 @@ export function UpdateBoardSlugForm({
register, register,
handleSubmit, handleSubmit,
formState: { isDirty, errors }, formState: { isDirty, errors },
watch,
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
values: { values: {
@@ -60,10 +57,6 @@ export function UpdateBoardSlugForm({
mode: "onChange", mode: "onChange",
}); });
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const updateBoardSlug = api.board.update.useMutation({ const updateBoardSlug = api.board.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -78,20 +71,6 @@ export function UpdateBoardSlugForm({
}, },
}); });
const checkBoardSlugAvailability =
api.board.checkSlugAvailability.useQuery(
{
boardSlug: debouncedSlug,
boardPublicId,
},
{
enabled:
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
},
);
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
useEffect(() => { useEffect(() => {
const nameElement: HTMLElement | null = const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#board-slug"); document.querySelector<HTMLElement>("#board-slug");
@@ -99,9 +78,6 @@ export function UpdateBoardSlugForm({
}, []); }, []);
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
if (!isBoardSlugAvailable) return;
if (isBoardSlugAvailable?.isReserved) return;
updateBoardSlug.mutate({ updateBoardSlug.mutate({
slug: data.slug, slug: data.slug,
boardPublicId, boardPublicId,
@@ -130,23 +106,14 @@ export function UpdateBoardSlugForm({
<Input <Input
id="board-slug" id="board-slug"
{...register("slug")} {...register("slug")}
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved errorMessage={errors.slug?.message}
? "This board URL has already been taken" prefix={`kan.bn/${workspaceSlug}/`}
: undefined)}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
await handleSubmit(onSubmit)(); await handleSubmit(onSubmit)();
} }
}} }}
iconRight={
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
<HiXMark className="h-4 w-4 text-red-500" />
) : (
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
)
}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
@@ -157,8 +124,7 @@ export function UpdateBoardSlugForm({
disabled={ disabled={
!isDirty || !isDirty ||
updateBoardSlug.isPending || updateBoardSlug.isPending ||
errors.slug?.message !== undefined || errors.slug?.message !== undefined
isBoardSlugAvailable?.isReserved
} }
> >
Update Update

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2"; import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -21,7 +20,6 @@ const VisibilityButton = ({
}: { }: {
visibility: "public" | "private"; visibility: "public" | "private";
boardPublicId: string; boardPublicId: string;
boardSlug: string;
queryParams: QueryParams; queryParams: QueryParams;
isLoading: boolean; isLoading: boolean;
isAdmin: boolean; isAdmin: boolean;
@@ -40,11 +38,7 @@ const VisibilityButton = ({
const updateBoardVisibility = api.board.update.useMutation({ const updateBoardVisibility = api.board.update.useMutation({
onSuccess: () => { onSuccess: () => {
showPopup({ setStateVisibility(isPublic ? "private" : "public");
header: "Board visibility updated",
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
icon: "success",
});
}, },
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -58,39 +52,23 @@ const VisibilityButton = ({
}, },
}); });
const handleUpdateBoardVisibility = () => {
updateBoardVisibility.mutate({
visibility: isPublic ? "private" : "public",
boardPublicId,
});
};
return ( return (
<div className="relative"> <Button
<CheckboxDropdown variant="secondary"
items={[ onClick={handleUpdateBoardVisibility}
{ iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
key: "public", isLoading={updateBoardVisibility.isPending}
value: "Public", disabled={isLoading || !isAdmin}
selected: isPublic, >
}, {isPublic ? "Public" : "Private"}
{ </Button>
key: "private",
value: "Private",
selected: !isPublic,
},
]}
handleSelect={(_g, i) => {
setStateVisibility(isPublic ? "private" : "public");
updateBoardVisibility.mutate({
visibility: i.key as "public" | "private",
boardPublicId,
});
}}
menuSpacing="md"
>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !isAdmin}
>
Visibility
</Button>
</CheckboxDropdown>
</div>
); );
}; };

View File

@@ -240,12 +240,11 @@ export default function BoardPage() {
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
<PatternedBackground /> <PatternedBackground />
<div className="z-10 flex w-full justify-between p-8"> <div className="z-10 flex w-full justify-between p-8">
{isLoading && !boardData && ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div> </div>
)} ) : (
{boardData && (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none" className="focus-visible:outline-none"
@@ -259,26 +258,20 @@ export default function BoardPage() {
/> />
</form> </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>
)}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<VisibilityButton <VisibilityButton
visibility={boardData?.visibility ?? "private"} visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams} queryParams={queryParams}
isLoading={!boardData} isLoading={isLoading}
isAdmin={workspace.role === "admin"} isAdmin={workspace.role === "admin"}
/> />
<Filters <Filters
labels={boardData?.labels ?? []} labels={boardData?.labels ?? []}
members={boardData?.workspace.members ?? []} members={boardData?.workspace.members ?? []}
position="left" position="left"
isLoading={!boardData} isLoading={isLoading}
/> />
<Button <Button
iconLeft={ iconLeft={
@@ -290,11 +283,11 @@ export default function BoardPage() {
onClick={() => { onClick={() => {
if (boardId) openNewListForm(boardId); if (boardId) openNewListForm(boardId);
}} }}
disabled={!boardData} disabled={isLoading}
> >
New list New list
</Button> </Button>
<BoardDropdown isLoading={!boardData} /> <BoardDropdown isLoading={isLoading} />
</div> </div>
</div> </div>
@@ -305,7 +298,7 @@ export default function BoardPage() {
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div> </div>
) : boardData ? ( ) : (
<> <>
{boardData?.lists.length === 0 ? ( {boardData?.lists.length === 0 ? (
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]"> <div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
@@ -357,7 +350,7 @@ export default function BoardPage() {
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600" className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
> >
{list.cards.map((card, index) => ( {list.cards.map((card, index) => (
<Draggable <Draggable
@@ -411,7 +404,7 @@ export default function BoardPage() {
</DragDropContext> </DragDropContext>
)} )}
</> </>
) : null} )}
</div> </div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}> <Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{modalContentType === "DELETE_BOARD" && ( {modalContentType === "DELETE_BOARD" && (

View File

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

View File

@@ -8,11 +8,11 @@ import {
HiOutlineUserMinus, HiOutlineUserMinus,
HiOutlineUserPlus, HiOutlineUserPlus,
} from "react-icons/hi2"; } from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types"; import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Comment from "./Comment"; import Comment from "./Comment";
import { authClient } from "@kan/auth/client";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; NonNullable<GetCardByIdOutput>["activities"][number]["type"];
@@ -147,8 +147,6 @@ const ActivityList = ({
isLoading: boolean; isLoading: boolean;
isAdmin?: boolean; isAdmin?: boolean;
}) => { }) => {
const { data } = authClient.useSession();
return ( return (
<div className="flex flex-col space-y-4 pt-4"> <div className="flex flex-col space-y-4 pt-4">
{activities.map((activity, index) => { {activities.map((activity, index) => {
@@ -158,7 +156,7 @@ const ActivityList = ({
fromList: activity.fromList?.name ?? null, fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null, toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null, memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user?.id, isSelf: activity.member?.user?.id === activity.user?.id,
label: activity.label?.name ?? null, label: activity.label?.name ?? null,
}); });
@@ -174,7 +172,7 @@ const ActivityList = ({
createdAt={activity.createdAt} createdAt={activity.createdAt}
comment={activity.comment?.comment} comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt} isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user?.id} isAuthor={activity.comment?.createdBy === activity.user?.id}
isAdmin={isAdmin ?? false} isAdmin={isAdmin ?? false}
/> />
); );

View File

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

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
@@ -23,7 +24,6 @@ import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector"; import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector"; import MemberSelector from "./components/MemberSelector";
import NewCommentForm from "./components/NewCommentForm"; import NewCommentForm from "./components/NewCommentForm";
import Editor from "~/components/Editor";
interface FormValues { interface FormValues {
cardId: string; cardId: string;
@@ -137,8 +137,6 @@ export default function CardPage() {
}); });
}; };
const description = watch("description");
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
@@ -150,13 +148,12 @@ export default function CardPage() {
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8"> <div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
<div className="mb-8 flex w-full items-center justify-between"> <div className="mb-8 flex w-full items-center justify-between">
{!card && isLoading && ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div> </div>
)} ) : (
{card && (
<> <>
<Link <Link
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]" className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
@@ -187,46 +184,40 @@ export default function CardPage() {
</div> </div>
</> </>
)} )}
{!card && !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]">
Card not found
</p>
)}
</div> </div>
{card && ( <div className="mb-10 flex w-full max-w-2xl justify-between">
<> <form
<div className="mb-10 flex w-full max-w-2xl justify-between"> onSubmit={handleSubmit(onSubmit)}
<form className="w-full space-y-6"
onSubmit={handleSubmit(onSubmit)} >
className="w-full space-y-6" <div className="mt-2">
> <ContentEditable
<div className="mt-2"> placeholder="Add description..."
<Editor html={watch("description")}
content={card.description} disabled={false}
onChange={(e) => setValue("description", e)} onChange={(e) => setValue("description", e.target.value)}
onBlur={() => handleSubmit(onSubmit)()} onBlur={handleSubmit(onSubmit)}
/> className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
</div> />
</form>
</div> </div>
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400"> </form>
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000"> </div>
Activity <div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
</h2> <h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
<div> Activity
<ActivityList </h2>
cardPublicId={cardId} <div>
activities={activities ?? []} <ActivityList
isLoading={!card} cardPublicId={cardId}
isAdmin={workspace.role === "admin"} activities={activities ?? []}
/> isLoading={isLoading}
</div> isAdmin={workspace.role === "admin"}
<div className="mt-6"> />
<NewCommentForm cardPublicId={cardId} /> </div>
</div> <div className="mt-6">
</div> <NewCommentForm cardPublicId={cardId} />
</> </div>
)} </div>
</div> </div>
</div> </div>
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"> <div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
@@ -235,7 +226,7 @@ export default function CardPage() {
<ListSelector <ListSelector
cardPublicId={cardId} cardPublicId={cardId}
lists={formattedLists} lists={formattedLists}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
<div className="mb-4 flex w-full"> <div className="mb-4 flex w-full">
@@ -243,7 +234,7 @@ export default function CardPage() {
<LabelSelector <LabelSelector
cardPublicId={cardId} cardPublicId={cardId}
labels={formattedLabels} labels={formattedLabels}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
<div className="flex w-full"> <div className="flex w-full">
@@ -251,7 +242,7 @@ export default function CardPage() {
<MemberSelector <MemberSelector
cardPublicId={cardId} cardPublicId={cardId}
members={formattedMembers} members={formattedMembers}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
</div> </div>

View File

@@ -1,15 +1,22 @@
import Cookies from "js-cookie";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
import Footer from "./Footer"; import Footer from "./Footer";
import Header from "./Header"; import Header from "./Header";
import { authClient } from "@kan/auth/client";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const theme = useTheme(); const theme = useTheme();
const { data: session } = authClient.useSession(); const token =
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
const isLoggedIn = !!session?.user; const { data } = api.user.getUser.useQuery(undefined, {
enabled: !!token,
});
const isLoggedIn = !!data;
const isDarkMode = theme.activeTheme === "dark"; const isDarkMode = theme.activeTheme === "dark";

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io"; import { IoLogoGithub } from "react-icons/io";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -21,41 +21,18 @@ export default function HomeView() {
<div className="flex h-full w-full flex-col lg:pt-[5rem]"> <div className="flex h-full w-full flex-col lg:pt-[5rem]">
<div className="w-full pb-10 pt-32 lg:py-32"> <div className="w-full pb-10 pt-32 lg:py-32">
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4"> <div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
<div className="flex items-center gap-2"> <div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400"> <div className="gradient-border absolute inset-0 animate-border-spin" />
<div className="gradient-border absolute inset-0 animate-border-spin" /> <div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50"> href="https://github.com/kanbn/kan"
<Link rel="noopener noreferrer"
href="https://github.com/kanbn/kan" target="_blank"
rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
target="_blank" >
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm" Star on Github
> <IoLogoGithub size={20} />
Star on Github </Link>
<IoLogoGithub size={20} />
</Link>
</div>
</div>
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://news.ycombinator.com/item?id=44157177"
rel="noopener noreferrer"
target="_blank"
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
>
#1 Hacker News
<div className="relative">
<div className="absolute inset-1 bg-white" />
<IoLogoHackernews
size={20}
className="relative text-orange-500"
/>
</div>
</Link>
</div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -15,19 +15,14 @@ export function DeleteWorkspaceConfirmation() {
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false); const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const utils = api.useUtils();
const deleteWorkspaceMutation = api.workspace.delete.useMutation({ const deleteWorkspaceMutation = api.workspace.delete.useMutation({
onSuccess: async () => { onSuccess: () => {
closeModal(); closeModal();
showPopup({ showPopup({
header: "Workspace deleted", header: "Workspace deleted",
message: "Your workspace has been deleted.", message: "Your workspace has been deleted.",
icon: "success", icon: "success",
}); });
await utils.workspace.all.refetch();
const filteredWorkspaces = availableWorkspaces.filter( const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId, (ws) => ws.publicId !== workspace.publicId,
); );

View File

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

View File

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

View File

@@ -1,115 +1,40 @@
version: "3.7"
services: services:
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports: ports:
- "${WEB_PORT:-3000}:3000" - "3001:3000"
networks: networks:
- kan-network
- dokploy-network - dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
env_file: env_file:
- .env - .env
command: ["pnpm", "start"]
environment: environment:
# 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}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- EMAIL_URL=${EMAIL_URL}
# S3 storage (optional) - EMAIL_TOKEN=${EMAIL_TOKEN}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
- 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}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT} - 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_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# 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}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID}
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET}
- GITLAB_ISSUER=${GITLAB_ISSUER}
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
- TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID}
- TWITTER_CLIENT_SECRET=${TWITTER_CLIENT_SECRET}
- KICK_CLIENT_ID=${KICK_CLIENT_ID}
- KICK_CLIENT_SECRET=${KICK_CLIENT_SECRET}
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
- DROPBOX_CLIENT_ID=${DROPBOX_CLIENT_ID}
- DROPBOX_CLIENT_SECRET=${DROPBOX_CLIENT_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
- LINKEDIN_CLIENT_ID=${LINKEDIN_CLIENT_ID}
- LINKEDIN_CLIENT_SECRET=${LINKEDIN_CLIENT_SECRET}
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID}
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET}
- ROBLOX_CLIENT_ID=${ROBLOX_CLIENT_ID}
- ROBLOX_CLIENT_SECRET=${ROBLOX_CLIENT_SECRET}
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- TIKTOK_CLIENT_ID=${TIKTOK_CLIENT_ID}
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
- TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID}
- TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET}
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
# Cloud (not required)
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
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: networks:
kan-network:
dokploy-network: dokploy-network:
external: true external: true
volumes:
kan_postgres_data:

View File

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

View File

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

View File

@@ -352,40 +352,4 @@ export const boardRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
checkSlugAvailability: publicProcedure
.meta({
openapi: {
summary: "Check if a board slug is available",
method: "GET",
path: "/boards/{boardPublicId}/check-slug-availability",
description: "Checks if a board slug is available",
tags: ["Boards"],
protect: true,
},
})
.input(
z.object({
boardSlug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardPublicId: z.string().min(12),
}),
)
.output(
z.object({
isReserved: z.boolean(),
}),
)
.query(async ({ ctx, input }) => {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
ctx.db,
input.boardSlug,
input.boardPublicId,
);
return {
isReserved: !isBoardSlugAvailable,
};
}),
}); });

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { apiKey } from "better-auth/plugins"; import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link"; import { magicLink } from "better-auth/plugins/magic-link";
import { socialProviderList } from "better-auth/social-providers";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
@@ -14,99 +12,13 @@ import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email"; import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
export const configuredProviders = socialProviderList.reduce<
Record<
string,
{
clientId: string;
clientSecret: string;
appBundleIdentifier?: string;
tenantId?: string;
requireSelectAccount?: boolean;
clientKey?: string;
issuer?: string;
}
>
>((acc, provider) => {
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
if (id && id.length > 0 && secret && secret.length > 0) {
acc[provider] = { clientId: id, clientSecret: secret };
}
if (
provider === "apple" &&
Object.keys(acc).includes("apple") &&
acc[provider]
) {
const bundleId =
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
if (bundleId && bundleId.length > 0) {
acc[provider].appBundleIdentifier = bundleId;
}
}
if (
provider === "gitlab" &&
Object.keys(acc).includes("gitlab") &&
acc[provider]
) {
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
if (issuer && issuer.length > 0) {
acc[provider].issuer = issuer;
}
}
if (
provider === "microsoft" &&
Object.keys(acc).includes("microsoft") &&
acc[provider]
) {
acc[provider].tenantId = "common";
acc[provider].requireSelectAccount = true;
}
if (
provider === "tiktok" &&
Object.keys(acc).includes("tiktok") &&
acc[provider]
) {
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
if (key && key.length > 0) {
acc[provider].clientKey = key;
}
}
return acc;
}, {});
export const socialProvidersPlugin = () => ({
id: "social-providers-plugin",
endpoints: {
getSocialProviders: createAuthEndpoint(
"/social-providers",
{
method: "GET",
},
async (ctx) =>
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
),
},
});
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
export const initAuth = (db: dbClient) => { export const initAuth = (db: dbClient) => {
return betterAuth({ return betterAuth({
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.NEXT_PUBLIC_BASE_URL!, baseURL: process.env.BETTER_AUTH_BASE_URL!,
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? [ ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
process.env.NEXT_PUBLIC_BASE_URL!, : [],
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
]
: [process.env.NEXT_PUBLIC_BASE_URL!],
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {
@@ -114,22 +26,13 @@ export const initAuth = (db: dbClient) => {
user: schema.users, user: schema.users,
}, },
}), }),
emailAndPassword: { socialProviders: {
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true", google: {
disableSignUp: clientId: process.env.GOOGLE_CLIENT_ID!,
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true", clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
sendResetPassword: async (data) => {
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
resetPasswordUrl: data.url,
resetPasswordToken: data.token,
});
}, },
}, },
socialProviders: configuredProviders,
user: { user: {
deleteUser: {
enabled: true,
},
additionalFields: { additionalFields: {
stripeCustomerId: { stripeCustomerId: {
type: "string", type: "string",
@@ -140,7 +43,6 @@ export const initAuth = (db: dbClient) => {
}, },
}, },
plugins: [ plugins: [
socialProvidersPlugin(),
// @todo: hasing is disabled due to a bug in the api key plugin // @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }), apiKey({ disableKeyHashing: true }),
magicLink({ magicLink({
@@ -163,58 +65,6 @@ export const initAuth = (db: dbClient) => {
}, },
}), }),
], ],
databaseHooks: {
user: {
create: {
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") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
credentials: {
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",
},
});
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 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 userRepo.update(db, user.id, {
image: key,
});
} catch (error) {
console.error(error);
}
}
},
},
},
},
hooks: { hooks: {
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/get-session")) { if (ctx.path.startsWith("/get-session")) {

View File

@@ -1,25 +1,6 @@
import { BetterAuthClientPlugin } from "better-auth";
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
import { BetterFetchOption, createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { socialProvidersPlugin } from "./auth";
const socialProvidersPluginClient = {
id: "social-providers-plugin",
$InferServerPlugin: {} as ReturnType<typeof socialProvidersPlugin>,
getActions: ($fetch) => {
return {
getSocialProviders: async (fetchOptions?: BetterFetchOption) => {
const res = $fetch("/social-providers", {
method: "GET",
...fetchOptions,
});
return res.then((res) => res.data as string[]);
},
};
},
} satisfies BetterAuthClientPlugin;
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient], plugins: [magicLinkClient(), apiKeyClient()],
}); });

View File

@@ -1,8 +1,6 @@
{ {
"extends": "@kan/tsconfig/internal-package.json", "extends": "@kan/tsconfig/internal-package.json",
"compilerOptions": { "compilerOptions": {},
"jsx": "react-jsx"
},
"include": ["*.ts", "src"], "include": ["*.ts", "src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -1,79 +0,0 @@
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_fromListId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_toListId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_labelId_label_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_activity" DROP CONSTRAINT "card_activity_commentId_card_comments_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_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
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_fromListId_list_id_fk" FOREIGN KEY ("fromListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_toListId_list_id_fk" FOREIGN KEY ("toListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "public"."label"("id") ON DELETE cascade 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 cascade 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 cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade 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 cascade 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 cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,34 +22,6 @@
"when": 1748378293342, "when": 1748378293342,
"tag": "20250527203813_AddDeletedAtToLabel", "tag": "20250527203813_AddDeletedAtToLabel",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"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

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema"; import type { BoardVisibilityStatus } from "@kan/db/schema";
@@ -477,34 +477,3 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
return result; return result;
}; };
export const isBoardSlugAvailable = async (
db: dbClient,
boardSlug: string,
boardPublicId: string,
) => {
const result = await db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.publicId, boardPublicId),
exists(
db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.slug, boardSlug),
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
isNull(boards.deletedAt),
),
)
.limit(1),
),
),
)
.limit(1);
return result.length === 0;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,12 @@ import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace"; import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link"; import MagicLinkTemplate from "./templates/magic-link";
import ResetPasswordTemplate from "./templates/reset-password";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD"; type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
const emailTemplates: Record<Templates, React.FC> = { const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate, MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate, JOIN_WORKSPACE: JoinWorkspaceTemplate,
RESET_PASSWORD: ResetPasswordTemplate,
}; };
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({

View File

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

1192
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,45 +47,8 @@
}, },
"globalEnv": [ "globalEnv": [
"POSTGRES_URL", "POSTGRES_URL",
"TRELLO_APP_API_KEY",
"TRELLO_APP_SECRET",
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET",
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"GITLAB_CLIENT_ID",
"GITLAB_CLIENT_SECRET",
"GITLAB_ISSUER",
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"TWITTER_CLIENT_ID",
"TWITTER_CLIENT_SECRET",
"KICK_CLIENT_ID",
"KICK_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"DROPBOX_CLIENT_ID",
"DROPBOX_CLIENT_SECRET",
"VK_CLIENT_ID",
"VK_CLIENT_SECRET",
"LINKEDIN_CLIENT_ID",
"LINKEDIN_CLIENT_SECRET",
"REDDIT_CLIENT_ID",
"REDDIT_CLIENT_SECRET",
"ROBLOX_CLIENT_ID",
"ROBLOX_CLIENT_SECRET",
"SPOTIFY_CLIENT_ID",
"SPOTIFY_CLIENT_SECRET",
"TIKTOK_CLIENT_ID",
"TIKTOK_CLIENT_SECRET",
"TIKTOK_CLIENT_KEY",
"TWITCH_CLIENT_ID",
"TWITCH_CLIENT_SECRET",
"APPLE_CLIENT_ID",
"APPLE_CLIENT_SECRET",
"APPLE_APP_BUNDLE_IDENTIFIER",
"EMAIL_FROM", "EMAIL_FROM",
"SMTP_HOST", "SMTP_HOST",
"SMTP_PORT", "SMTP_PORT",
@@ -98,14 +61,13 @@
"NEXT_PUBLIC_STORAGE_DOMAIN", "NEXT_PUBLIC_STORAGE_DOMAIN",
"NEXT_PUBLIC_STORAGE_URL", "NEXT_PUBLIC_STORAGE_URL",
"NEXT_PUBLIC_AVATAR_BUCKET_NAME", "NEXT_PUBLIC_AVATAR_BUCKET_NAME",
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
"NEXT_PUBLIC_DISABLE_SIGN_UP",
"S3_REGION", "S3_REGION",
"S3_ACCESS_KEY_ID", "S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY", "S3_SECRET_ACCESS_KEY",
"S3_ENDPOINT", "S3_ENDPOINT",
"PORT", "PORT",
"BETTER_AUTH_SECRET", "BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"BETTER_AUTH_TRUSTED_ORIGINS" "BETTER_AUTH_TRUSTED_ORIGINS"
], ],
"globalPassThroughEnv": [ "globalPassThroughEnv": [