Compare commits
1 Commits
fix/docker
...
fix/8-self
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec17d76ebf |
35
.env.example
35
.env.example
@@ -1,39 +1,24 @@
|
||||
# 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=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,6 +46,3 @@ dist/
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# runtime env
|
||||
__ENV.js
|
||||
126
README.md
126
README.md
@@ -47,67 +47,34 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
### PostgreSQL Database Setup
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
```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:
|
||||
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Run PostgreSQL in a container using the official postgres image
|
||||
docker run -d \
|
||||
--name kan-db \
|
||||
-e POSTGRES_DB=kan \
|
||||
-e POSTGRES_USER=kan_user \
|
||||
-e POSTGRES_PASSWORD=your_secure_password \
|
||||
-p 5432:5432 \
|
||||
-v kan_postgres_data:/var/lib/postgresql/data \
|
||||
postgres:15
|
||||
|
||||
# Your POSTGRES_URL should be:
|
||||
# postgres://kan_user:your_secure_password@your_host:5432/kan
|
||||
```
|
||||
|
||||
3. Access Kan at http://localhost:3000
|
||||
### Kan Application Deployment
|
||||
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
Deploy Kan with Docker using our pre-built image:
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To restart the containers: `docker compose restart`
|
||||
```bash
|
||||
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
|
||||
```
|
||||
|
||||
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.
|
||||
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
@@ -138,34 +105,31 @@ 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 | Yes | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 @@ ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["/bin/sh", "./entrypoint.sh"]
|
||||
# Run migration on start for now (until we have a better way to handle this)
|
||||
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
|
||||
@@ -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:",
|
||||
@@ -47,12 +41,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": {
|
||||
|
||||
1
apps/web/public/__ENV.js
Normal file
1
apps/web/public/__ENV.js
Normal 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"};
|
||||
@@ -1,52 +1,27 @@
|
||||
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 { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
|
||||
import { SiRoblox, SiZoom } from "react-icons/si";
|
||||
import { TbBrandKick } from "react-icons/tb";
|
||||
import { z } from "zod";
|
||||
import type { SocialProvider } from "better-auth/social-providers";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
const EmailSchema = z.object({ email: z.string().email() });
|
||||
|
||||
const availableSocialProviders = {
|
||||
google: {
|
||||
@@ -139,22 +114,19 @@ const availableSocialProviders = {
|
||||
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 [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
||||
null | SocialProvider
|
||||
>(null);
|
||||
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),
|
||||
});
|
||||
@@ -164,67 +136,27 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
"Something went wrong, please try again later or contact customer support.",
|
||||
);
|
||||
} else {
|
||||
setIsMagicLinkSent(true, email);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||
const handleLoginWithProvider = async (
|
||||
provider: SocialProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
@@ -242,11 +174,9 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
};
|
||||
|
||||
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 && (
|
||||
@@ -256,17 +186,16 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -279,60 +208,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid name
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid password
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -15,18 +15,9 @@ 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(),
|
||||
@@ -69,7 +60,7 @@ export const env = createEnv({
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
EMAIL_FROM: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -79,24 +70,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 +86,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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
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 { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
@@ -51,14 +48,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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
@@ -116,9 +115,9 @@ export function NewCardForm({
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to create card",
|
||||
message: error.data?.zodError?.fieldErrors.title?.[0]
|
||||
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
|
||||
: "Please try again later, or contact customer support.",
|
||||
message: error.data?.zodError?.fieldErrors.title?.[0] ?
|
||||
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
|
||||
"Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -254,12 +253,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 +389,7 @@ export function NewCardForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={title.length === 0 || createCard.isPending}
|
||||
>
|
||||
Create card
|
||||
</Button>
|
||||
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -260,16 +260,13 @@ export default function BoardPage() {
|
||||
</form>
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
Board not found
|
||||
</p>
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">Board not found</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
@@ -357,7 +354,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
|
||||
@@ -377,13 +374,12 @@ export default function BoardPage() {
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={`/cards/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
@@ -201,10 +199,13 @@ export default function CardPage() {
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import baseConfig from "@kan/tailwind-config/web";
|
||||
|
||||
export default {
|
||||
content: [...baseConfig.content],
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
presets: [baseConfig],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
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)
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
|
||||
# S3 storage (optional)
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
# Auth config (optional)
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# Integration providers (optional)
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
# OAuth providers (optional)
|
||||
- BETTER_AUTH_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}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
@@ -81,35 +62,16 @@ services:
|
||||
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||
|
||||
# Cloud (not required)
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- 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:
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||
import { apiKey } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { socialProviderList } from "better-auth/social-providers";
|
||||
import { env } from "next-runtime-env";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
@@ -13,6 +12,7 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as schema from "@kan/db/schema";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { socialProviderList } from "better-auth/social-providers";
|
||||
|
||||
export const configuredProviders = socialProviderList.reduce<
|
||||
Record<
|
||||
@@ -83,8 +83,7 @@ export const socialProvidersPlugin = () => ({
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) =>
|
||||
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -100,13 +99,10 @@ async function downloadImage(url: string): Promise<Buffer> {
|
||||
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 +110,8 @@ export const initAuth = (db: dbClient) => {
|
||||
user: schema.users,
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||
disableSignUp:
|
||||
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
||||
sendResetPassword: async (data) => {
|
||||
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||
resetPasswordUrl: data.url,
|
||||
resetPasswordToken: data.token,
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: configuredProviders,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
additionalFields: {
|
||||
stripeCustomerId: {
|
||||
type: "string",
|
||||
@@ -166,17 +148,8 @@ 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!)
|
||||
) {
|
||||
async after(user, _context) {
|
||||
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
|
||||
try {
|
||||
const client = new S3Client({
|
||||
region: env("S3_REGION") ?? "",
|
||||
@@ -189,21 +162,18 @@ export const initAuth = (db: dbClient) => {
|
||||
|
||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
const fileExtension =
|
||||
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||
const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
|
||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
|
||||
|
||||
const imageBuffer = await downloadImage(user.image);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: key,
|
||||
Body: imageBuffer,
|
||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: key,
|
||||
Body: imageBuffer,
|
||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
|
||||
ACL: 'public-read',
|
||||
}));
|
||||
await userRepo.update(db, user.id, {
|
||||
image: key,
|
||||
});
|
||||
@@ -211,9 +181,9 @@ export const initAuth = (db: dbClient) => {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
|
||||
@@ -76,4 +76,4 @@ DO $$ BEGIN
|
||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
END $$;
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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
@@ -29,27 +29,6 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
};
|
||||
@@ -81,6 +81,5 @@ export const apiKeyRelations = relations(apikey, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [apikey.userId],
|
||||
references: [users.id],
|
||||
relationName: "apiKeyUser",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
@@ -96,29 +90,23 @@ export const cardActivities = pgTable("card_activity", {
|
||||
fromIndex: integer("fromIndex"),
|
||||
toIndex: integer("toIndex"),
|
||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||
() => lists.id,
|
||||
{ onDelete: "cascade" },
|
||||
() => lists.id, { onDelete: "cascade" },
|
||||
),
|
||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
|
||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }),
|
||||
workspaceMemberId: bigint("workspaceMemberId", {
|
||||
mode: "number",
|
||||
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
|
||||
}).references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||
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, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
commentId: bigint("commentId", { mode: "number" }).references(
|
||||
() => comments.id,
|
||||
{ onDelete: "cascade" },
|
||||
() => comments.id, { onDelete: "cascade" },
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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, { onDelete: "cascade" }),
|
||||
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, { onDelete: "cascade" }),
|
||||
}).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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -7,5 +7,4 @@ export * from "./imports";
|
||||
export * from "./labels";
|
||||
export * from "./lists";
|
||||
export * from "./users";
|
||||
export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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't try to reset your password, you can safely ignore this email.
|
||||
</Text>
|
||||
<Hr
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2rem",
|
||||
borderWidth: "1px",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
Kan
|
||||
</Link>
|
||||
, the open source Trello alternative.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
export default ResetPasswordTemplate;
|
||||
1172
pnpm-lock.yaml
generated
1172
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
46
turbo.json
46
turbo.json
@@ -3,28 +3,49 @@
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"topo": {
|
||||
"dependsOn": ["^topo"]
|
||||
"dependsOn": [
|
||||
"^topo"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/tsbuildinfo.json",
|
||||
"dist/**"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["^dev"],
|
||||
"dependsOn": [
|
||||
"^dev"
|
||||
],
|
||||
"cache": false,
|
||||
"persistent": false
|
||||
},
|
||||
"format": {
|
||||
"outputs": [".cache/.prettiercache"],
|
||||
"outputs": [
|
||||
".cache/.prettiercache"
|
||||
],
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^topo", "^build"],
|
||||
"outputs": [".cache/.eslintcache"]
|
||||
"dependsOn": [
|
||||
"^topo",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/.eslintcache"
|
||||
]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^topo", "^build"],
|
||||
"outputs": [".cache/tsbuildinfo.json"]
|
||||
"dependsOn": [
|
||||
"^topo",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/tsbuildinfo.json"
|
||||
]
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
@@ -47,8 +68,6 @@
|
||||
},
|
||||
"globalEnv": [
|
||||
"POSTGRES_URL",
|
||||
"TRELLO_APP_API_KEY",
|
||||
"TRELLO_APP_SECRET",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"DISCORD_CLIENT_ID",
|
||||
@@ -98,14 +117,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": [
|
||||
@@ -116,4 +134,4 @@
|
||||
"VERCEL_URL",
|
||||
"npm_lifecycle_event"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user