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
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)
EMAIL_FROM=
SMTP_HOST=
SMTP_PORT=465 # Port 587 will not work
SMTP_PORT=
SMTP_USER=
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_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID=
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
with:
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)
# https://github.com/docker/build-push-action
@@ -83,7 +76,6 @@ jobs:
context: .
file: apps/web/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha

3
.gitignore vendored
View File

@@ -46,6 +46,3 @@ dist/
# docker
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)
- [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 🧑‍💻
1. Clone the repository (or fork)
@@ -138,34 +74,27 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
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.
## Contributors 👥
<a href="https://github.com/kanbn/kan/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -3,25 +3,31 @@ title: Trello
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**
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
1. **Sign in to your Trello account**
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
2. **Log in to your Trello account**
You will be redirected to Trello to log in to your Trello account.
2. **Create a new integration**
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
3. **Authorize Kan.bn for Trello**
Approve the permissions and you will be redirected back to Kan.bn.
- **Name**: Choose a name for your integration (e.g. "Kan Import")
- **Workspace**: Select the workspace containing the boards you want to import
- **Email**: Enter your email address
- **Support email**: Enter your email address
- **Author**: Add your name
4. **Verify the connection**
You should see a **Disconnect Trello** button.
3. **Get a Trello API Key**
Now click **Generate a new API key** to create an API key.
4. **Generate a Trello Token**
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
## 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**
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**
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.

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
hosting, scaling, and maintenance for you.
</Card>
<Card
title="Self-host"
icon="code"
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
>
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
For full data ownership and control, you can self-host Kan on your
infrastructure.
</Card>

View File

@@ -31,7 +31,7 @@ COPY . .
# 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" }
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
RUN turbo prune --scope=${PROJECT} --docker
# 3. Build the project
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 --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT}
# Copy static files to standalone directory
RUN mkdir -p apps/web/.next/standalone/.next && \
mv apps/web/public apps/web/.next/standalone/ && \
mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
@@ -81,5 +75,5 @@ ARG PORT=3000
ENV PORT=${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} */
const config = {
output: "standalone",
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
@@ -32,10 +31,6 @@ const config = {
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",

View File

@@ -23,13 +23,7 @@
"@kan/db": "workspace:^",
"@kan/shared": "workspace:^",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@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/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
@@ -37,6 +31,8 @@
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "^14.2.15",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
@@ -47,12 +43,10 @@
"react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^5.5.0",
"react-icons": "^4.12.0",
"react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"zod": "catalog:"
},
"devDependencies": {
@@ -61,6 +55,7 @@
"@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@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 { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
FaApple,
FaDiscord,
FaDropbox,
FaFacebook,
FaGithub,
FaGitlab,
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaReddit,
FaSpotify,
FaTiktok,
FaTwitch,
FaTwitter,
FaVk,
} from "react-icons/fa";
import { SiRoblox, SiZoom } from "react-icons/si";
import { TbBrandKick } from "react-icons/tb";
import { FaGoogle } from "react-icons/fa";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
interface FormValues {
name?: string;
email: string;
password?: string;
}
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
}
const EmailSchema = z.object({
name: z.string().optional(),
email: z.string().email(),
password: z.string().optional(),
});
const EmailSchema = z.object({ email: z.string().email() });
const availableSocialProviders = {
google: {
id: "google",
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";
export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
useState(false);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormValues>({
resolver: zodResolver(EmailSchema),
});
const { data: socialProviders } = useQuery({
queryKey: ["social_providers"],
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (
email: string,
password?: string,
name?: string,
) => {
const handleLoginWithEmail = async (email: string) => {
setIsLoginWithEmailPending(true);
setLoginError(null);
if (password) {
if (isSignUp && name) {
await authClient.signUp.email(
{
name,
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been signed up successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
} else {
await authClient.signIn.email(
{
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been logged in successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
}
} else {
await authClient.signIn.magicLink(
{
email,
callbackURL: "/boards",
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
}
setIsLoginWithEmailPending(false);
};
const handleLoginWithProvider = async (provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider,
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/boards",
});
setIsLoginWithProviderPending(null);
setIsLoginWithEmailPending(false);
if (error) {
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) => {
await handleLoginWithEmail(values.email, values.password, values.name);
await handleLoginWithEmail(values.email);
};
const password = watch("password");
return (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
<div className="space-y-2">
{Object.entries(availableSocialProviders).map(([key, provider]) => {
if (!socialProviders?.includes(key)) {
return null;
}
return (
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
);
})}
</div>
)}
<div>
<Button
onClick={handleLoginWithGoogle}
isLoading={isLoginWithGooglePending}
iconLeft={<FaGoogle />}
fullWidth
size="lg"
>
Continue with Google
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<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" />
<span className="text-sm text-light-900 dark:text-dark-900">
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 className="mb-[1.5rem] flex w-full items-center gap-4">
<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>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</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
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
{isSignUp ? "Sign up with " : "Continue with "}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
Continue with email
</Button>
</div>
</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(
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
prefix && "rounded-l-none",
type === "password" && "pr-8",
className && className,
)}
onKeyDown={onKeyDown}
@@ -76,7 +75,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{type === "password" && (
<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}
onClick={() => setShowPassword((v) => !v)}
>

View File

@@ -15,61 +15,19 @@ export const env = createEnv({
*/
server: {
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) =>
!s ||
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
BETTER_AUTH_URL: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
POSTGRES_URL: z.string().url(),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
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_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: 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: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_DISABLE_SIGN_UP: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -109,8 +53,6 @@ export const env = createEnv({
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
},
skipValidation:
!!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 auth = initAuth(createDrizzleClient());
const auth = initAuth(createDrizzleClient());
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 { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
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 { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"];
@@ -56,7 +54,7 @@ export default async function handler(
// @ts-ignore
client,
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
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 { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useState } from "react";
import { authClient } from "@kan/auth/client";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
// import { api } from "~/utils/api";
export default function LoginPage() {
const router = useRouter();
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -20,9 +18,15 @@ export default function LoginPage() {
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 (
<>
@@ -51,14 +55,12 @@ export default function LoginPage() {
</div>
</div>
)}
{!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
</div>
<PatternedBackground />
</div>

View File

@@ -1,16 +1,15 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "@kan/auth/client";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { env } from "next-runtime-env";
// import { api } from "~/utils/api";
export default function SignupPage() {
const router = useRouter();
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -19,18 +18,15 @@ export default function SignupPage() {
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") {
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>
)
}
// if (data?.id) router.push("/boards");
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="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
</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 { useForm } from "react-hook-form";
import {
HiEllipsisHorizontal,
HiOutlinePlusSmall,
HiOutlineSquaresPlus,
HiOutlineTrash,
} from "react-icons/hi2";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
interface ListProps {
children: ReactNode;
@@ -79,20 +80,20 @@ export default function List({
{...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"
>
<div className="mb-2 flex justify-between">
<div className="flex justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full focus-visible:outline-none"
className="focus-visible:outline-none"
>
<input
id="name"
type="text"
{...register("name")}
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>
<div className="flex items-center">
<div>
<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"
onClick={() => openNewCardForm(list.publicId)}

View File

@@ -12,7 +12,6 @@ import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle";
@@ -64,7 +63,6 @@ export function NewCardForm({
const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position");
const title = watch("title");
const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId,
@@ -112,29 +110,16 @@ export function NewCardForm({
return { previousState: currentState };
},
onError: (error, _newList, context) => {
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to create card",
message: error.data?.zodError?.fieldErrors.title?.[0]
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
: "Please try again later, or contact customer support.",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
onSuccess: async () => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
onSettled: async () => {
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 isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
createCard.mutate({
title: data.title,
description: data.description,
@@ -254,12 +251,18 @@ export function NewCardForm({
/>
</div>
<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">
<Editor
content=""
onChange={(value) => setValue("description", value)}
/>
</div>
<Input
placeholder="Add description..."
onChange={(e) => setValue("description", e.target.value)}
value={watch("description")}
contentEditable
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-2 flex space-x-1">
<div className="w-fit">
@@ -384,12 +387,7 @@ export function NewCardForm({
/>
<div>
<Button
type="submit"
disabled={title.length === 0 || createCard.isPending}
>
Create card
</Button>
<Button type="submit">Create card</Button>
</div>
</div>
</form>

View File

@@ -1,13 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -51,7 +49,6 @@ export function UpdateBoardSlugForm({
register,
handleSubmit,
formState: { isDirty, errors },
watch,
} = useForm<FormValues>({
resolver: zodResolver(schema),
values: {
@@ -60,10 +57,6 @@ export function UpdateBoardSlugForm({
mode: "onChange",
});
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const updateBoardSlug = api.board.update.useMutation({
onError: () => {
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(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#board-slug");
@@ -99,9 +78,6 @@ export function UpdateBoardSlugForm({
}, []);
const onSubmit = (data: FormValues) => {
if (!isBoardSlugAvailable) return;
if (isBoardSlugAvailable?.isReserved) return;
updateBoardSlug.mutate({
slug: data.slug,
boardPublicId,
@@ -130,23 +106,14 @@ export function UpdateBoardSlugForm({
<Input
id="board-slug"
{...register("slug")}
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
? "This board URL has already been taken"
: undefined)}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
errorMessage={errors.slug?.message}
prefix={`kan.bn/${workspaceSlug}/`}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
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 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={
!isDirty ||
updateBoardSlug.isPending ||
errors.slug?.message !== undefined ||
isBoardSlugAvailable?.isReserved
errors.slug?.message !== undefined
}
>
Update

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,22 @@
import Cookies from "js-cookie";
import PatternedBackground from "~/components/PatternedBackground";
import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
import Footer from "./Footer";
import Header from "./Header";
import { authClient } from "@kan/auth/client";
export default function Layout({ children }: { children: React.ReactNode }) {
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";

View File

@@ -1,6 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
import { IoLogoGithub } from "react-icons/io";
import Button from "~/components/Button";
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="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="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="gradient-border absolute inset-0 animate-border-spin" />
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://github.com/kanbn/kan"
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"
>
Star on Github
<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 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="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://github.com/kanbn/kan"
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"
>
Star on Github
<IoLogoGithub size={20} />
</Link>
</div>
</div>

View File

@@ -1,7 +1,5 @@
import { authClient } from "@kan/auth/client";
import { useRouter } from "next/router";
import { HiXMark } from "react-icons/hi2";
import Editor from "~/components/Editor";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
@@ -18,7 +16,6 @@ export function CardModal({
}) {
const router = useRouter();
const { closeModal, isOpen } = useModal();
const { data: session } = authClient.useSession();
const { data, isLoading } = api.card.byId.useQuery(
{
@@ -93,7 +90,9 @@ export function CardModal({
{data?.description && (
<div className="mb-10 flex w-full max-w-2xl justify-between">
<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>
)}

View File

@@ -133,7 +133,7 @@ export default function PublicBoardView() {
{list.name}
</span>
</div>
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar 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) => (
<Link
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 utils = api.useUtils();
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
onSuccess: async () => {
onSuccess: () => {
closeModal();
showPopup({
header: "Workspace deleted",
message: "Your workspace has been deleted.",
icon: "success",
});
await utils.workspace.all.refetch();
const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId,
);

View File

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

View File

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

View File

@@ -1,115 +1,40 @@
version: "3.7"
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "${WEB_PORT:-3000}:3000"
- "3001:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
env_file:
- .env
command: ["pnpm", "start"]
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}
# S3 storage (optional)
- EMAIL_URL=${EMAIL_URL}
- 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_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# 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}
- 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:
kan-network:
dokploy-network:
external: true
volumes:
kan_postgres_data:

View File

@@ -9,5 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "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 { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
@@ -20,7 +19,6 @@ export const appRouter = createTRPCRouter({
import: importRouter,
user: userRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});
export type AppRouter = typeof appRouter;

View File

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

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 { 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 { magicLink } from "better-auth/plugins/magic-link";
import { socialProviderList } from "better-auth/social-providers";
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
@@ -14,99 +12,13 @@ import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email";
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) => {
return betterAuth({
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
? [
process.env.NEXT_PUBLIC_BASE_URL!,
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
]
: [process.env.NEXT_PUBLIC_BASE_URL!],
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
: [],
database: drizzleAdapter(db, {
provider: "pg",
schema: {
@@ -114,22 +26,13 @@ export const initAuth = (db: dbClient) => {
user: schema.users,
},
}),
emailAndPassword: {
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
disableSignUp:
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
sendResetPassword: async (data) => {
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
resetPasswordUrl: data.url,
resetPasswordToken: data.token,
});
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
socialProviders: configuredProviders,
user: {
deleteUser: {
enabled: true,
},
additionalFields: {
stripeCustomerId: {
type: "string",
@@ -140,7 +43,6 @@ export const initAuth = (db: dbClient) => {
},
},
plugins: [
socialProvidersPlugin(),
// @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }),
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: {
after: createAuthMiddleware(async (ctx) => {
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 { BetterFetchOption, 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;
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
plugins: [magicLinkClient(), apiKeyClient()],
});

View File

@@ -1,8 +1,6 @@
{
"extends": "@kan/tsconfig/internal-package.json",
"compilerOptions": {
"jsx": "react-jsx"
},
"compilerOptions": {},
"include": ["*.ts", "src"],
"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,
"tag": "20250527203813_AddDeletedAtToLabel",
"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 { BoardVisibilityStatus } from "@kan/db/schema";
@@ -477,34 +477,3 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
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, {
fields: [apikey.userId],
references: [users.id],
relationName: "apiKeyUser",
}),
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,12 @@ import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link";
import ResetPasswordTemplate from "./templates/reset-password";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate,
RESET_PASSWORD: ResetPasswordTemplate,
};
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": [
"POSTGRES_URL",
"TRELLO_APP_API_KEY",
"TRELLO_APP_SECRET",
"GOOGLE_CLIENT_ID",
"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",
"SMTP_HOST",
"SMTP_PORT",
@@ -98,14 +61,13 @@
"NEXT_PUBLIC_STORAGE_DOMAIN",
"NEXT_PUBLIC_STORAGE_URL",
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
"NEXT_PUBLIC_DISABLE_SIGN_UP",
"S3_REGION",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"S3_ENDPOINT",
"PORT",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"BETTER_AUTH_TRUSTED_ORIGINS"
],
"globalPassThroughEnv": [