Compare commits
60 Commits
v0.1.0
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfdec1bbf | ||
|
|
f0e3c4c3e2 | ||
|
|
d6b0b5cd80 | ||
|
|
1707557bea | ||
|
|
f6114a56af | ||
|
|
c1d0bb3c93 | ||
|
|
09ae4292ac | ||
|
|
75e89f118f | ||
|
|
dd6cdfc208 | ||
|
|
7758bd77ea | ||
|
|
294ed2ffc2 | ||
|
|
e2786f31b0 | ||
|
|
bfc69996c6 | ||
|
|
96f2835bab | ||
|
|
53426bf155 | ||
|
|
17c2a1b8ec | ||
|
|
d203f834cc | ||
|
|
75ad0d8f98 | ||
|
|
4066e6bba7 | ||
|
|
8aa4aa2a7c | ||
|
|
43164a0d8a | ||
|
|
8474601b2e | ||
|
|
d768b1e6ba | ||
|
|
6a7f2f148d | ||
|
|
cbc90c3002 | ||
|
|
efe83ec8a9 | ||
|
|
d1d37840fc | ||
|
|
4135e316f4 | ||
|
|
805ef8cf43 | ||
|
|
e590de80c0 | ||
|
|
f64549f743 | ||
|
|
ce2ef1b1d4 | ||
|
|
56b0161c6f | ||
|
|
314053fa95 | ||
|
|
f41736c1aa | ||
|
|
e53efe80c5 | ||
|
|
2043a8f98c | ||
|
|
116fefcaf3 | ||
|
|
f88bd82044 | ||
|
|
6eb17529f1 | ||
|
|
4b7f58aeeb | ||
|
|
86a01074b9 | ||
|
|
c9a9779e79 | ||
|
|
356e35964b | ||
|
|
1698b744e4 | ||
|
|
27c0c0a5e8 | ||
|
|
2129a60d22 | ||
|
|
84dd9a9b6d | ||
|
|
3625d702e3 | ||
|
|
2047f5a9ea | ||
|
|
fcb97908e0 | ||
|
|
348d81a705 | ||
|
|
cef670dc11 | ||
|
|
23ae7a375b | ||
|
|
0b06f80181 | ||
|
|
37d2ebccf7 | ||
|
|
1027b45cdb | ||
|
|
f590937ddb | ||
|
|
a29a2f6e16 | ||
|
|
7e7ba2c0da |
@@ -1,5 +1,7 @@
|
||||
.env
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
|
||||
49
.env.example
49
.env.example
@@ -1,22 +1,63 @@
|
||||
POSTGRES_URL=
|
||||
|
||||
EMAIL_FROM=
|
||||
EMAIL_URL=
|
||||
EMAIL_TOKEN=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
NEXT_PUBLIC_BASE_URL=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
GITLAB_ISSUER=
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
TWITTER_CLIENT_ID=
|
||||
TWITTER_CLIENT_SECRET=
|
||||
KICK_CLIENT_ID=
|
||||
KICK_CLIENT_SECRET=
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
DROPBOX_CLIENT_ID=
|
||||
DROPBOX_CLIENT_SECRET=
|
||||
VK_CLIENT_ID=
|
||||
VK_CLIENT_SECRET=
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
REDDIT_CLIENT_ID=
|
||||
REDDIT_CLIENT_SECRET=
|
||||
ROBLOX_CLIENT_ID=
|
||||
ROBLOX_CLIENT_SECRET=
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
TIKTOK_CLIENT_ID=
|
||||
TIKTOK_CLIENT_SECRET=
|
||||
TIKTOK_CLIENT_KEY=
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: "\U0001F41B Bug Report"
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Report
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- OS: [e.g. macOS, Windows, Linux]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Node version [e.g. 20]
|
||||
- App version/commit [if applicable]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: "✨ Feature Request"
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Request
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Implementation ideas (optional)**
|
||||
If you have any ideas about how this feature could be implemented, please share them here.
|
||||
|
||||
**Would you like to work on this feature?**
|
||||
|
||||
- [ ] Yes, I'd like to help implement this feature
|
||||
- [ ] No, I'm just suggesting the feature
|
||||
105
.github/workflows/docker-publish.yml
vendored
Normal file
105
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Docker
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -43,3 +43,9 @@ dist/
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# runtime env
|
||||
__ENV.js
|
||||
84
README.md
84
README.md
@@ -32,6 +32,10 @@
|
||||
|
||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Screenshot 👁️
|
||||
|
||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
|
||||
|
||||
## Made With 🛠️
|
||||
|
||||
- [Next.js](https://nextjs.org/?ref=kan.bn)
|
||||
@@ -41,7 +45,38 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
||||
- [React Email](https://react.email/?ref=kan.bn)
|
||||
|
||||
## Local Development 🛠️
|
||||
## Self Hosting 🐳
|
||||
|
||||
### PostgreSQL Database Setup
|
||||
|
||||
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
|
||||
|
||||
```bash
|
||||
# Run PostgreSQL in a container using the official postgres image
|
||||
docker run -d \
|
||||
--name kan-db \
|
||||
-e POSTGRES_DB=kan \
|
||||
-e POSTGRES_USER=kan_user \
|
||||
-e POSTGRES_PASSWORD=your_secure_password \
|
||||
-p 5432:5432 \
|
||||
-v kan_postgres_data:/var/lib/postgresql/data \
|
||||
postgres:15
|
||||
|
||||
# Your POSTGRES_URL should be:
|
||||
# postgres://kan_user:your_secure_password@your_host:5432/kan
|
||||
```
|
||||
|
||||
### Kan Application Deployment
|
||||
|
||||
Deploy Kan with Docker using our pre-built image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
|
||||
```
|
||||
|
||||
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
1. Clone the repository (or fork)
|
||||
|
||||
@@ -56,16 +91,61 @@ pnpm install
|
||||
```
|
||||
|
||||
3. Copy `.env.example` to `.env` and configure your environment variables
|
||||
4. Start the development server
|
||||
4. Migrate database
|
||||
|
||||
```bash
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
5. Start the development server
|
||||
|
||||
```bash
|
||||
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` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
## Contributing 🤝
|
||||
|
||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
## Contributors 👥
|
||||
|
||||
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -3,31 +3,25 @@ title: Trello
|
||||
description: "Import your Trello boards to Kan."
|
||||
---
|
||||
|
||||
## Getting your Trello API key and token
|
||||
## Authorizing Kan.bn for Trello
|
||||
|
||||
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
|
||||
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes):
|
||||
|
||||
1. **Sign in to your Trello account**
|
||||
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
|
||||
1. **Authorize Kan.bn for Trello**
|
||||
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
|
||||
|
||||
2. **Create a new integration**
|
||||
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
|
||||
2. **Log in to your Trello account**
|
||||
You will be redirected to Trello to log in to your Trello account.
|
||||
|
||||
- **Name**: Choose a name for your integration (e.g. "Kan Import")
|
||||
- **Workspace**: Select the workspace containing the boards you want to import
|
||||
- **Email**: Enter your email address
|
||||
- **Support email**: Enter your email address
|
||||
- **Author**: Add your name
|
||||
3. **Authorize Kan.bn for Trello**
|
||||
Approve the permissions and you will be redirected back to Kan.bn.
|
||||
|
||||
3. **Get a Trello API Key**
|
||||
Now click **Generate a new API key** to create an API key.
|
||||
|
||||
4. **Generate a Trello Token**
|
||||
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
|
||||
4. **Verify the connection**
|
||||
You should see a **Disconnect Trello** button.
|
||||
|
||||
## Importing your Trello boards
|
||||
|
||||
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
|
||||
Once you have authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps:
|
||||
|
||||
1. **Create a new import in Kan**
|
||||
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
||||
@@ -35,8 +29,5 @@ Once you have your API key and token, importing your Trello boards is easy. Foll
|
||||
2. **Select Trello as the Import Source**
|
||||
Choose **Trello** from the list of import options and click **Select source**.
|
||||
|
||||
3. **Enter Your Trello API Key and Token**
|
||||
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
|
||||
|
||||
4. **Start the Import**
|
||||
3. **Start the Import**
|
||||
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
||||
|
||||
@@ -25,7 +25,11 @@ Get started with Kan by choosing your preferred deployment option below.
|
||||
Get started instantly with our hosted solution - we'll handle all the
|
||||
hosting, scaling, and maintenance for you.
|
||||
</Card>
|
||||
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
|
||||
<Card
|
||||
title="Self-host"
|
||||
icon="code"
|
||||
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
|
||||
>
|
||||
For full data ownership and control, you can self-host Kan on your
|
||||
infrastructure.
|
||||
</Card>
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
RUN turbo prune --scope=${PROJECT} --docker
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
@@ -42,13 +42,6 @@ ENV CI=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||
ARG NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
ARG NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
ARG NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
ARG NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
ARG NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
@@ -82,5 +75,6 @@ ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
# 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,9 +1,13 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import createJiti from "jiti";
|
||||
import { env } from "next-runtime-env";
|
||||
import { configureRuntimeEnv } from "next-runtime-env/build/configure.js";
|
||||
|
||||
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
|
||||
createJiti(fileURLToPath(import.meta.url))("./src/env");
|
||||
|
||||
configureRuntimeEnv();
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
@@ -22,7 +26,20 @@ const config = {
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
images: {
|
||||
domains: [process.env.NEXT_PUBLIC_STORAGE_DOMAIN ?? ""],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
|
||||
@@ -23,7 +23,13 @@
|
||||
"@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:",
|
||||
@@ -31,10 +37,9 @@
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"react": "catalog:react18",
|
||||
@@ -42,10 +47,12 @@
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "catalog:react18",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,7 +61,6 @@
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
|
||||
@@ -1,115 +1,338 @@
|
||||
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 { FaGoogle } from "react-icons/fa";
|
||||
import {
|
||||
FaApple,
|
||||
FaDiscord,
|
||||
FaDropbox,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaGitlab,
|
||||
FaGoogle,
|
||||
FaLinkedin,
|
||||
FaMicrosoft,
|
||||
FaReddit,
|
||||
FaSpotify,
|
||||
FaTiktok,
|
||||
FaTwitch,
|
||||
FaTwitter,
|
||||
FaVk,
|
||||
} from "react-icons/fa";
|
||||
import { SiRoblox, SiZoom } from "react-icons/si";
|
||||
import { TbBrandKick } from "react-icons/tb";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({ email: z.string().email() });
|
||||
const EmailSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
||||
useState(false);
|
||||
const availableSocialProviders = {
|
||||
google: {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: FaGoogle,
|
||||
},
|
||||
github: {
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: FaGithub,
|
||||
},
|
||||
discord: {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
icon: FaDiscord,
|
||||
},
|
||||
apple: {
|
||||
id: "apple",
|
||||
name: "Apple",
|
||||
icon: FaApple,
|
||||
},
|
||||
microsoft: {
|
||||
id: "microsoft",
|
||||
name: "Microsoft",
|
||||
icon: FaMicrosoft,
|
||||
},
|
||||
facebook: {
|
||||
id: "facebook",
|
||||
name: "Facebook",
|
||||
icon: FaFacebook,
|
||||
},
|
||||
spotify: {
|
||||
id: "spotify",
|
||||
name: "Spotify",
|
||||
icon: FaSpotify,
|
||||
},
|
||||
twitch: {
|
||||
id: "twitch",
|
||||
name: "Twitch",
|
||||
icon: FaTwitch,
|
||||
},
|
||||
twitter: {
|
||||
id: "twitter",
|
||||
name: "Twitter",
|
||||
icon: FaTwitter,
|
||||
},
|
||||
dropbox: {
|
||||
id: "dropbox",
|
||||
name: "Dropbox",
|
||||
icon: FaDropbox,
|
||||
},
|
||||
linkedin: {
|
||||
id: "linkedin",
|
||||
name: "LinkedIn",
|
||||
icon: FaLinkedin,
|
||||
},
|
||||
gitlab: {
|
||||
id: "gitlab",
|
||||
name: "GitLab",
|
||||
icon: FaGitlab,
|
||||
},
|
||||
tiktok: {
|
||||
id: "tiktok",
|
||||
name: "TikTok",
|
||||
icon: FaTiktok,
|
||||
},
|
||||
reddit: {
|
||||
id: "reddit",
|
||||
name: "Reddit",
|
||||
icon: FaReddit,
|
||||
},
|
||||
roblox: {
|
||||
id: "roblox",
|
||||
name: "Roblox",
|
||||
icon: SiRoblox,
|
||||
},
|
||||
vk: {
|
||||
id: "vk",
|
||||
name: "VK",
|
||||
icon: FaVk,
|
||||
},
|
||||
kick: {
|
||||
id: "kick",
|
||||
name: "Kick",
|
||||
icon: TbBrandKick,
|
||||
},
|
||||
zoom: {
|
||||
id: "zoom",
|
||||
name: "Zoom",
|
||||
icon: SiZoom,
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | SocialProvider>(null);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(EmailSchema),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (email: string) => {
|
||||
const { data: socialProviders } = useQuery({
|
||||
queryKey: ["social_providers"],
|
||||
queryFn: () => authClient.getSocialProviders(),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (
|
||||
email: string,
|
||||
password?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
if (password) {
|
||||
if (isSignUp && name) {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: "Success",
|
||||
message: "You have been signed up successfully.",
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: "Success",
|
||||
message: "You have been logged in successfully.",
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
};
|
||||
|
||||
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
setIsLoginWithProviderPending(null);
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
"Something went wrong, please try again later or contact customer support.",
|
||||
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
);
|
||||
} else {
|
||||
setIsMagicLinkSent(true, email);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithGoogle = async () => {
|
||||
setIsLoginWithGooglePending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithGooglePending(false);
|
||||
|
||||
if (error) {
|
||||
setLoginError("Failed to login with Google. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await handleLoginWithEmail(values.email);
|
||||
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||
};
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleLoginWithGoogle}
|
||||
isLoading={isLoginWithGooglePending}
|
||||
iconLeft={<FaGoogle />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(availableSocialProviders).map(([key, provider]) => {
|
||||
if (!socialProviders?.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
or
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
<div className="mt-[1.5rem]">
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid name
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid password
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
Continue with email
|
||||
{isSignUp ? "Sign up with " : "Continue with "}
|
||||
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||
? "email"
|
||||
: "magic link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
419
apps/web/src/components/Editor.tsx
Normal file
419
apps/web/src/components/Editor.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -98,12 +98,16 @@ const FeedbackButton: React.FC = () => {
|
||||
onChange={(e) => {
|
||||
setValue("feedback", e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
value={watch("feedback")}
|
||||
contentEditable
|
||||
className="max-h-[300px] min-h-[100px]"
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
<div>
|
||||
|
||||
@@ -67,14 +67,16 @@ 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}
|
||||
{...props}
|
||||
/>
|
||||
{type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||
|
||||
@@ -103,6 +103,12 @@ export function LabelForm({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#label-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
@@ -111,6 +117,7 @@ export function LabelForm({
|
||||
{isEdit ? "Edit label" : "New label"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -121,7 +128,17 @@ export function LabelForm({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input id="label-name" placeholder="Name" {...register("name")} />
|
||||
<Input
|
||||
id="label-name"
|
||||
placeholder="Name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="colour"
|
||||
control={control}
|
||||
|
||||
@@ -61,6 +61,7 @@ export function NewWorkspaceForm() {
|
||||
New workspace
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -75,6 +76,12 @@ export function NewWorkspaceForm() {
|
||||
id="workspace-name"
|
||||
placeholder="Workspace name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -15,19 +15,58 @@ export const env = createEnv({
|
||||
*/
|
||||
server: {
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
BETTER_AUTH_URL: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||
.string()
|
||||
.refine((s) =>
|
||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||
)
|
||||
.optional(),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
TRELLO_APP_API_KEY: z.string().optional(),
|
||||
TRELLO_APP_SECRET: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
DISCORD_CLIENT_ID: z.string().optional(),
|
||||
DISCORD_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GITLAB_CLIENT_ID: z.string().optional(),
|
||||
GITLAB_CLIENT_SECRET: z.string().optional(),
|
||||
GITLAB_ISSUER: z.string().optional(),
|
||||
MICROSOFT_CLIENT_ID: z.string().optional(),
|
||||
MICROSOFT_CLIENT_SECRET: z.string().optional(),
|
||||
TWITTER_CLIENT_ID: z.string().optional(),
|
||||
TWITTER_CLIENT_SECRET: z.string().optional(),
|
||||
KICK_CLIENT_ID: z.string().optional(),
|
||||
KICK_CLIENT_SECRET: z.string().optional(),
|
||||
ZOOM_CLIENT_ID: z.string().optional(),
|
||||
ZOOM_CLIENT_SECRET: z.string().optional(),
|
||||
DROPBOX_CLIENT_ID: z.string().optional(),
|
||||
DROPBOX_CLIENT_SECRET: z.string().optional(),
|
||||
VK_CLIENT_ID: z.string().optional(),
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
REDDIT_CLIENT_ID: z.string().optional(),
|
||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||
ROBLOX_CLIENT_SECRET: z.string().optional(),
|
||||
SPOTIFY_CLIENT_ID: z.string().optional(),
|
||||
SPOTIFY_CLIENT_SECRET: z.string().optional(),
|
||||
TIKTOK_CLIENT_ID: z.string().optional(),
|
||||
TIKTOK_CLIENT_SECRET: z.string().optional(),
|
||||
TIKTOK_CLIENT_KEY: z.string().optional(),
|
||||
TWITCH_CLIENT_ID: z.string().optional(),
|
||||
TWITCH_CLIENT_SECRET: z.string().optional(),
|
||||
APPLE_CLIENT_ID: z.string().optional(),
|
||||
APPLE_CLIENT_SECRET: z.string().optional(),
|
||||
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
EMAIL_FROM: z.string(),
|
||||
EMAIL_URL: z.string(),
|
||||
EMAIL_TOKEN: z.string(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -37,10 +76,18 @@ export const env = createEnv({
|
||||
client: {
|
||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||
.optional(),
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||
.string()
|
||||
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||
.optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
@@ -53,6 +100,8 @@ 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",
|
||||
|
||||
@@ -2,8 +2,8 @@ import "~/styles/globals.css";
|
||||
|
||||
import type { AppType } from "next/app";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
import { PopupProvider } from "~/providers/popup";
|
||||
import { ThemeProvider } from "~/providers/theme";
|
||||
@@ -31,13 +31,14 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
position: relative;
|
||||
}
|
||||
`}</style>
|
||||
{env.NEXT_PUBLIC_UMAMI_ID && (
|
||||
{env("NEXT_PUBLIC_UMAMI_ID") && (
|
||||
<script
|
||||
defer
|
||||
src="https://cloud.umami.is/script.js"
|
||||
data-website-id={env.NEXT_PUBLIC_UMAMI_ID}
|
||||
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
|
||||
/>
|
||||
)}
|
||||
<script src="/__ENV.js" />
|
||||
<main className="font-sans">
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
|
||||
import { initAuth } from "@kan/auth";
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
const auth = initAuth(createDrizzleClient());
|
||||
export const auth = initAuth(createDrizzleClient());
|
||||
|
||||
export default toNodeHandler(auth.handler);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
@@ -22,7 +23,7 @@ export default async function handler(
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settings`,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
@@ -73,8 +74,8 @@ export default async function handler(
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}${successUrl}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}${cancelUrl}`,
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
workspaceSlug: slug,
|
||||
|
||||
51
apps/web/src/pages/api/trello/authenticate.ts
Normal file
51
apps/web/src/pages/api/trello/authenticate.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
|
||||
if (!apiKey)
|
||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
||||
|
||||
const token = req.body.token;
|
||||
|
||||
if (!token)
|
||||
return res.status(400).json({ message: "No token found" });
|
||||
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
await db.insert(integrations).values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
@@ -13,14 +18,37 @@ export default async function handler(
|
||||
}
|
||||
|
||||
try {
|
||||
const { filename, contentType } = req.body;
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { filename, contentType } = req.body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
// Specific to avatar uploads for now
|
||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
||||
|
||||
if (!filenameRegex.test(filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof contentType !== "string" ||
|
||||
!allowedContentTypes.includes(contentType)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION,
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +56,7 @@ export default async function handler(
|
||||
// @ts-ignore
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME ?? "",
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
}),
|
||||
);
|
||||
|
||||
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal file
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TrelloAuthorize() {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.split("=")[1];
|
||||
if (token) {
|
||||
console.log("Posting token to /api/trello/authenticate", token);
|
||||
fetch("/api/trello/authenticate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div className="flex h-[200px] items-center justify-center">
|
||||
<p className="text-center">Connecting to Trello...</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "~/env";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
@@ -45,6 +45,10 @@ export const formatMemberDisplayName = (
|
||||
return localPart.replace(/[_-]/g, ".");
|
||||
};
|
||||
|
||||
export const getAvatarUrl = (key: string) => {
|
||||
return `${env.NEXT_PUBLIC_STORAGE_URL}/${env.NEXT_PUBLIC_AVATAR_BUCKET_NAME}/${key}`;
|
||||
export const getAvatarUrl = (imageOrKey: string) => {
|
||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
// const router = useRouter();
|
||||
const router = useRouter();
|
||||
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
@@ -18,15 +20,9 @@ export default function LoginPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,12 +51,14 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</p>
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export default function SignupPage() {
|
||||
// const router = useRouter();
|
||||
const router = useRouter();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
@@ -18,15 +19,18 @@ export default function SignupPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
return (
|
||||
<div className="flex flex-col bg-light-100 dark:bg-dark-50 h-screen items-center justify-center">
|
||||
<p className="mb-2 text-light-1000 dark:text-dark-1000 font-semibold text-sm">Sign up is disabled</p>
|
||||
<Link href="/login" className="text-light-1000 dark:text-dark-1000 font-semibold text-sm rounded-md border border-light-900 dark:border-dark-900 px-4 py-2">Login</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,7 +55,7 @@ export default function SignupPage() {
|
||||
) : (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlineTrash,
|
||||
HiOutlineSquaresPlus,
|
||||
} from "react-icons/hi2";
|
||||
import type { ReactNode } from "react";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineSquaresPlus,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface ListProps {
|
||||
children: ReactNode;
|
||||
@@ -80,20 +79,20 @@ export default function List({
|
||||
{...provided.dragHandleProps}
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
className="w-full focus-visible:outline-none"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import Editor from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Toggle from "~/components/Toggle";
|
||||
@@ -63,6 +64,7 @@ export function NewCardForm({
|
||||
const memberPublicIds = watch("memberPublicIds") || [];
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
|
||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardPublicId,
|
||||
@@ -110,16 +112,29 @@ export function NewCardForm({
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
onError: (error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to create card",
|
||||
message: "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",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
onSuccess: async () => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,18 +179,6 @@ export function NewCardForm({
|
||||
})) ?? [];
|
||||
|
||||
const onSubmit = (data: NewCardInput) => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
|
||||
createCard.mutate({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -226,6 +229,7 @@ export function NewCardForm({
|
||||
New card
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
closeModal();
|
||||
@@ -237,15 +241,25 @@ export function NewCardForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input id="title" placeholder="Card title" {...register("title")} />
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Card title"
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
placeholder="Add description..."
|
||||
onChange={(e) => setValue("description", e.target.value)}
|
||||
value={watch("description")}
|
||||
contentEditable
|
||||
/>
|
||||
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
|
||||
<Editor
|
||||
content=""
|
||||
onChange={(value) => setValue("description", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex space-x-1">
|
||||
<div className="w-fit">
|
||||
@@ -370,7 +384,12 @@ export function NewCardForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">Create card</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={title.length === 0 || createCard.isPending}
|
||||
>
|
||||
Create card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -111,6 +111,7 @@ export function NewListForm({
|
||||
New list
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -121,7 +122,17 @@ export function NewListForm({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input id="list-name" placeholder="List name" {...register("name")} />
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="List name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -49,6 +51,7 @@ export function UpdateBoardSlugForm({
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
values: {
|
||||
@@ -57,6 +60,10 @@ export function UpdateBoardSlugForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const slug = watch("slug");
|
||||
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
|
||||
const updateBoardSlug = api.board.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -71,6 +78,20 @@ export function UpdateBoardSlugForm({
|
||||
},
|
||||
});
|
||||
|
||||
const checkBoardSlugAvailability =
|
||||
api.board.checkSlugAvailability.useQuery(
|
||||
{
|
||||
boardSlug: debouncedSlug,
|
||||
boardPublicId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
|
||||
},
|
||||
);
|
||||
|
||||
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#board-slug");
|
||||
@@ -78,6 +99,9 @@ export function UpdateBoardSlugForm({
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!isBoardSlugAvailable) return;
|
||||
if (isBoardSlugAvailable?.isReserved) return;
|
||||
|
||||
updateBoardSlug.mutate({
|
||||
slug: data.slug,
|
||||
boardPublicId,
|
||||
@@ -92,6 +116,7 @@ export function UpdateBoardSlugForm({
|
||||
Edit board URL
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -105,8 +130,23 @@ export function UpdateBoardSlugForm({
|
||||
<Input
|
||||
id="board-slug"
|
||||
{...register("slug")}
|
||||
errorMessage={errors.slug?.message}
|
||||
prefix={`kan.bn/${workspaceSlug}/`}
|
||||
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
|
||||
? "This board URL has already been taken"
|
||||
: undefined)}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
iconRight={
|
||||
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
|
||||
<HiXMark className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
@@ -117,7 +157,8 @@ export function UpdateBoardSlugForm({
|
||||
disabled={
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined
|
||||
errors.slug?.message !== undefined ||
|
||||
isBoardSlugAvailable?.isReserved
|
||||
}
|
||||
>
|
||||
Update
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -20,6 +21,7 @@ const VisibilityButton = ({
|
||||
}: {
|
||||
visibility: "public" | "private";
|
||||
boardPublicId: string;
|
||||
boardSlug: string;
|
||||
queryParams: QueryParams;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
@@ -38,7 +40,11 @@ const VisibilityButton = ({
|
||||
|
||||
const updateBoardVisibility = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
showPopup({
|
||||
header: "Board visibility updated",
|
||||
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -52,23 +58,39 @@ const VisibilityButton = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateBoardVisibility = () => {
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: isPublic ? "private" : "public",
|
||||
boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUpdateBoardVisibility}
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
isLoading={updateBoardVisibility.isPending}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
{isPublic ? "Public" : "Private"}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: "Public",
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: "Private",
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
Visibility
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -240,11 +240,12 @@ export default function BoardPage() {
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
{isLoading ? (
|
||||
{isLoading && !boardData && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{boardData && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
@@ -258,20 +259,26 @@ export default function BoardPage() {
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
Board not found
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members ?? []}
|
||||
position="left"
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
<Button
|
||||
iconLeft={
|
||||
@@ -283,11 +290,11 @@ export default function BoardPage() {
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={!boardData}
|
||||
>
|
||||
New list
|
||||
</Button>
|
||||
<BoardDropdown isLoading={isLoading} />
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -298,7 +305,7 @@ export default function BoardPage() {
|
||||
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
) : boardData ? (
|
||||
<>
|
||||
{boardData?.lists.length === 0 ? (
|
||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||
@@ -350,7 +357,7 @@ export default function BoardPage() {
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
|
||||
>
|
||||
{list.cards.map((card, index) => (
|
||||
<Draggable
|
||||
@@ -404,7 +411,7 @@ export default function BoardPage() {
|
||||
</DragDropContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
|
||||
@@ -1,37 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FaTrello } from "react-icons/fa";
|
||||
import {
|
||||
HiChevronUpDown,
|
||||
HiMiniArrowTopRightOnSquare,
|
||||
HiOutlineQuestionMarkCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface TrelloFormValues {
|
||||
apiKey: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const sources = [{ source: "Trello" }];
|
||||
const integrationProviders: Record<
|
||||
string,
|
||||
{ name: string; icon: JSX.Element }
|
||||
> = {
|
||||
trello: {
|
||||
name: "Trello",
|
||||
icon: <FaTrello />,
|
||||
},
|
||||
};
|
||||
|
||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
const { data: integrations, refetch: refetchIntegrations } =
|
||||
api.integration.providers.useQuery();
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
source: "Trello",
|
||||
source: integrations?.[0]?.provider ?? "trello",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: trelloUrl } = api.integration.getAuthorizationUrl.useQuery(
|
||||
{ provider: "trello" },
|
||||
{
|
||||
enabled: !integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const hasIntegrations = integrations && integrations.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const onSubmit = () => {
|
||||
handleNextStep();
|
||||
if (!hasIntegrations && trelloUrl) {
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
} else {
|
||||
handleNextStep();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -47,9 +78,9 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
<div className="relative">
|
||||
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
|
||||
<span className="flex items-center">
|
||||
<FaTrello />
|
||||
{integrationProviders[field.value]?.icon}
|
||||
<span className="ml-2 block truncate">
|
||||
{field.value}
|
||||
{integrationProviders[field.value]?.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
@@ -68,20 +99,41 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
|
||||
{sources.map(({ source }, index) => (
|
||||
{hasIntegrations ? (
|
||||
integrations.map((integration, index) => (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
className="relative cursor-default select-none px-1"
|
||||
value={integration.provider}
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.icon
|
||||
}
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
key="trello_placeholder"
|
||||
className="relative cursor-default select-none px-1"
|
||||
value={source}
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
<FaTrello className="ml-1" />
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
{source}
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -94,7 +146,14 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit">Select source</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
iconRight={
|
||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||
}
|
||||
>
|
||||
{hasIntegrations ? "Select source" : "Connect Trello"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -103,25 +162,26 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
|
||||
const ImportTrello: React.FC = () => {
|
||||
const utils = api.useUtils();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const boards = api.import.trello.getBoards.useQuery(
|
||||
{ apiKey, token },
|
||||
{
|
||||
enabled: apiKey && token ? true : false,
|
||||
},
|
||||
);
|
||||
const { data: boards, isLoading: boardsLoading } =
|
||||
api.import.trello.getBoards.useQuery();
|
||||
|
||||
const handleSetAuthDetails = (apiKey: string, token: string) => {
|
||||
setApiKey(apiKey);
|
||||
setToken(token);
|
||||
};
|
||||
const {
|
||||
register: registerBoards,
|
||||
handleSubmit: handleSubmitBoards,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
boards?.map((board) => [board.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
|
||||
const importBoards = api.import.trello.importBoards.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -146,83 +206,94 @@ const ImportTrello: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit } = useForm<TrelloFormValues>({
|
||||
defaultValues: {
|
||||
apiKey: "",
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: TrelloFormValues) => {
|
||||
handleSetAuthDetails(values.apiKey, values.token);
|
||||
};
|
||||
|
||||
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
|
||||
useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
boards.data?.map((board) => [board.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
const boardWatchers = boards?.map((board) => ({
|
||||
id: board.id,
|
||||
value: watch(board.id),
|
||||
}));
|
||||
|
||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||
|
||||
importBoards.mutate({
|
||||
boardIds,
|
||||
apiKey,
|
||||
token,
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
};
|
||||
|
||||
if (boards.data?.length)
|
||||
return (
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-scroll px-5">
|
||||
{boards.data.map((board) => (
|
||||
<div key={board.id}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||
htmlFor={board.id}
|
||||
>
|
||||
<input
|
||||
id={board.id}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
{...registerBoards(board.id)}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
const renderContent = () => {
|
||||
if (boardsLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={importBoards.isPending}>
|
||||
Import boards
|
||||
</Button>
|
||||
</div>
|
||||
if (!boards?.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
No boards found
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return boards.map((board) => (
|
||||
<div key={board.id}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||
htmlFor={board.id}
|
||||
>
|
||||
<input
|
||||
id={board.id}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
{...registerBoards(board.id)}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="text-neutral-900 dark:text-dark-1000"
|
||||
>
|
||||
<div className="space-y-4 px-5">
|
||||
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
|
||||
<Input id="token" placeholder="Token" {...register("token")} />
|
||||
</div>
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={boards.isLoading}>
|
||||
Fetch boards
|
||||
<Toggle
|
||||
label="Select all"
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
onChange={() => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
setIsSelectAllEnabled(newState);
|
||||
|
||||
for (const board of boards || []) {
|
||||
setValue(board.id, newState);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={importBoards.isPending}
|
||||
disabled={
|
||||
importBoards.isPending ||
|
||||
boardsLoading ||
|
||||
!boards?.length ||
|
||||
!boards.some(
|
||||
(board) =>
|
||||
boardWatchers?.find((w) => w.id === board.id)?.value === true,
|
||||
)
|
||||
}
|
||||
>
|
||||
Import boards (
|
||||
{boardWatchers?.filter((w) => w.value === true).length || 0})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,6 +322,7 @@ export function ImportBoardsForm() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { NewBoardInput } from "@kan/api/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -33,12 +35,19 @@ export function NewBoardForm() {
|
||||
createBoard.mutate(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#name");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">New board</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -48,11 +57,16 @@ export function NewBoardForm() {
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
{...register("name", { required: true })}
|
||||
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
HiOutlineUserMinus,
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Comment from "./Comment";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
@@ -147,6 +147,8 @@ const ActivityList = ({
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
{activities.map((activity, index) => {
|
||||
@@ -156,7 +158,7 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === activity.user?.id,
|
||||
isSelf: activity.member?.user?.id === data?.user?.id,
|
||||
label: activity.label?.name ?? null,
|
||||
});
|
||||
|
||||
@@ -172,7 +174,7 @@ const ActivityList = ({
|
||||
createdAt={activity.createdAt}
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === activity.user?.id}
|
||||
isAuthor={activity.comment?.createdBy === data?.user?.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -132,7 +132,11 @@ const Comment = ({
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<p className="mt-2 text-sm">{comment}</p>
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="mt-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ContentEditable
|
||||
|
||||
@@ -58,6 +58,12 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
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"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
@@ -24,6 +23,7 @@ import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
import NewCommentForm from "./components/NewCommentForm";
|
||||
import Editor from "~/components/Editor";
|
||||
|
||||
interface FormValues {
|
||||
cardId: string;
|
||||
@@ -137,6 +137,8 @@ export default function CardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const description = watch("description");
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
return (
|
||||
@@ -148,12 +150,13 @@ export default function CardPage() {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between">
|
||||
{isLoading ? (
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{card && (
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
@@ -184,40 +187,46 @@ export default function CardPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
Card not found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<ContentEditable
|
||||
placeholder="Add description..."
|
||||
html={watch("description")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("description", e.target.value)}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={isLoading}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
@@ -226,7 +235,7 @@ export default function CardPage() {
|
||||
<ListSelector
|
||||
cardPublicId={cardId}
|
||||
lists={formattedLists}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full">
|
||||
@@ -234,7 +243,7 @@ export default function CardPage() {
|
||||
<LabelSelector
|
||||
cardPublicId={cardId}
|
||||
labels={formattedLabels}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
@@ -242,7 +251,7 @@ export default function CardPage() {
|
||||
<MemberSelector
|
||||
cardPublicId={cardId}
|
||||
members={formattedMembers}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const token =
|
||||
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { data } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
const isLoggedIn = !!data;
|
||||
const isLoggedIn = !!session?.user;
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -21,18 +21,41 @@ export default function HomeView() {
|
||||
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
||||
<div className="w-full pb-10 pt-32 lg:py-32">
|
||||
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
Star on Github
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
Star on Github
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://news.ycombinator.com/item?id=44157177"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
#1 Hacker News
|
||||
<div className="relative">
|
||||
<div className="absolute inset-1 bg-white" />
|
||||
<IoLogoHackernews
|
||||
size={20}
|
||||
className="relative text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -34,12 +36,19 @@ export function InviteMemberForm() {
|
||||
createBoard.mutate(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const emailElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#email");
|
||||
if (emailElement) emailElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">Add member</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -49,11 +58,16 @@ export function InviteMemberForm() {
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
{...register("email", { required: true })}
|
||||
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import Editor from "~/components/Editor";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -16,6 +18,7 @@ export function CardModal({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal, isOpen } = useModal();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
@@ -90,9 +93,7 @@ export function CardModal({
|
||||
{data?.description && (
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<div className="mt-2">
|
||||
<p className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6">
|
||||
{data.description}
|
||||
</p>
|
||||
<Editor content={data.description} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function PublicBoardView() {
|
||||
{list.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600">
|
||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
{list.cards.map((card) => (
|
||||
<Link
|
||||
key={card.publicId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
@@ -62,7 +62,7 @@ export default function Avatar({
|
||||
setUploading(true);
|
||||
|
||||
const response = await fetch(
|
||||
env.NEXT_PUBLIC_BASE_URL + "/api/upload/image",
|
||||
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authClient } from "@kan/auth";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteAccountConfirmation() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: () => authClient.deleteUser(),
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Account deleted",
|
||||
message: "Your account has been deleted.",
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
utils.invalidate();
|
||||
|
||||
router.push("/");
|
||||
},
|
||||
onError: () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Error deleting account",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
deleteAccountMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{`Are you sure you want to delete your account?`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
Keep in mind that this action is irreversible.
|
||||
</p>
|
||||
<p className="text-sm text-light-900 dark:text-dark-900">
|
||||
This will result in the permanent deletion of all data associated with
|
||||
your account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
id="acknowledgment"
|
||||
name="acknowledgment"
|
||||
type="checkbox"
|
||||
aria-describedby="acknowledgment-description"
|
||||
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
|
||||
checked={isAcknowledgmentChecked}
|
||||
onChange={() =>
|
||||
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<p
|
||||
id="comments-description"
|
||||
className="text-light-900 dark:text-dark-1000"
|
||||
>
|
||||
I acknowledge that all of my account data will be permanently
|
||||
deleted and want to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isAcknowledgmentChecked}
|
||||
isLoading={deleteAccountMutation.isPending}
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,14 +15,19 @@ export function DeleteWorkspaceConfirmation() {
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Workspace deleted",
|
||||
message: "Your workspace has been deleted.",
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
await utils.workspace.all.refetch();
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter(
|
||||
(ws) => ws.publicId !== workspace.publicId,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiCheck, HiMiniStar } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
@@ -92,7 +93,9 @@ const UpdateWorkspaceUrlForm = ({
|
||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (isWorkspaceSlugAvailable?.isAvailable && workspacePlan !== "pro")
|
||||
if (!isWorkspaceSlugAvailable?.isAvailable) return;
|
||||
|
||||
if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud")
|
||||
return openModal("UPDATE_WORKSPACE_URL", data.slug);
|
||||
|
||||
updateWorkspaceSlug.mutate({
|
||||
@@ -118,7 +121,11 @@ const UpdateWorkspaceUrlForm = ({
|
||||
? "This workspace username has already been taken"
|
||||
: undefined)
|
||||
}
|
||||
prefix="kan.bn/"
|
||||
prefix={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||
}
|
||||
iconRight={
|
||||
isWorkspaceSlugAvailable?.isAvailable ||
|
||||
(workspacePlan === "pro" && slug === workspaceUrl) ? (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -5,11 +7,13 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import Avatar from "./components/Avatar";
|
||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
@@ -20,9 +24,54 @@ export default function SettingsPage() {
|
||||
const { modalContentType, openModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
isLoading: integrationsLoading,
|
||||
} = api.integration.providers.useQuery();
|
||||
|
||||
const { data: trelloUrl, refetch: refetchTrelloUrl } =
|
||||
api.integration.getAuthorizationUrl.useQuery({ provider: "trello" }, {
|
||||
enabled:
|
||||
!integrationsLoading &&
|
||||
!integrations?.some((integration) => integration.provider === "trello"),
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } = api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchUser();
|
||||
refetchIntegrations();
|
||||
refetchTrelloUrl();
|
||||
showPopup({
|
||||
header: "Trello disconnected",
|
||||
message: "Your Trello account has been disconnected.",
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Error disconnecting Trello",
|
||||
message: "An error occurred while disconnecting your Trello account.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const refetchUser = () => utils.user.getUser.refetch();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
@@ -95,7 +144,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<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">
|
||||
Billing
|
||||
@@ -113,6 +162,48 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
Trello
|
||||
</h2>
|
||||
{!integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
) && trelloUrl ? (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
Connect your Trello account to import boards.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
trelloUrl.url,
|
||||
"trello_auth",
|
||||
"height=800,width=600",
|
||||
)
|
||||
}
|
||||
>
|
||||
Connect Trello
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
You are already connected to Trello.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
disconnectTrello({ provider: "trello" });
|
||||
}}
|
||||
>
|
||||
Disconnect Trello
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
API keys
|
||||
@@ -126,20 +217,40 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
Delete workspace
|
||||
</h2>
|
||||
<p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
|
||||
Once you delete your workspace, there is no going back. Please
|
||||
be certain.
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
Once you delete your workspace, there is no going back. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
Delete account
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
Once you delete your account, there is no going back. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_ACCOUNT")}
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +262,9 @@ export default function SettingsPage() {
|
||||
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
||||
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
||||
)}
|
||||
{modalContentType === "DELETE_ACCOUNT" && (
|
||||
<DeleteAccountConfirmation />
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import baseConfig from "@kan/tailwind-config/web";
|
||||
|
||||
export default {
|
||||
content: [...baseConfig.content],
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
presets: [baseConfig],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
ports:
|
||||
- "3001:3000"
|
||||
networks:
|
||||
@@ -9,27 +8,59 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_KAN_ENV: ${NEXT_PUBLIC_KAN_ENV}
|
||||
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID}
|
||||
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}
|
||||
env_file:
|
||||
- .env
|
||||
command: ["pnpm", "start"]
|
||||
environment:
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- EMAIL_URL=${EMAIL_URL}
|
||||
- EMAIL_TOKEN=${EMAIL_TOKEN}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID}
|
||||
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET}
|
||||
- GITLAB_ISSUER=${GITLAB_ISSUER}
|
||||
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
|
||||
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
|
||||
- TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID}
|
||||
- TWITTER_CLIENT_SECRET=${TWITTER_CLIENT_SECRET}
|
||||
- KICK_CLIENT_ID=${KICK_CLIENT_ID}
|
||||
- KICK_CLIENT_SECRET=${KICK_CLIENT_SECRET}
|
||||
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
|
||||
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
|
||||
- DROPBOX_CLIENT_ID=${DROPBOX_CLIENT_ID}
|
||||
- DROPBOX_CLIENT_SECRET=${DROPBOX_CLIENT_SECRET}
|
||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||
- LINKEDIN_CLIENT_ID=${LINKEDIN_CLIENT_ID}
|
||||
- LINKEDIN_CLIENT_SECRET=${LINKEDIN_CLIENT_SECRET}
|
||||
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID}
|
||||
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET}
|
||||
- ROBLOX_CLIENT_ID=${ROBLOX_CLIENT_ID}
|
||||
- ROBLOX_CLIENT_SECRET=${ROBLOX_CLIENT_SECRET}
|
||||
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
||||
- TIKTOK_CLIENT_ID=${TIKTOK_CLIENT_ID}
|
||||
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
|
||||
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
|
||||
- TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID}
|
||||
- TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET}
|
||||
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
@@ -40,7 +71,8 @@ services:
|
||||
- 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}
|
||||
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "next-runtime-env";
|
||||
import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter } from "./root";
|
||||
@@ -6,7 +7,7 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||
title: "Kan API",
|
||||
description: "OpenAPI compliant REST API",
|
||||
version: "1.0.0",
|
||||
baseUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1`,
|
||||
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
||||
docsUrl: "docs.kan.bn",
|
||||
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
|
||||
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { boardRouter } from "./routers/board";
|
||||
import { cardRouter } from "./routers/card";
|
||||
import { feedbackRouter } from "./routers/feedback";
|
||||
import { importRouter } from "./routers/import";
|
||||
import { integrationRouter } from "./routers/integration";
|
||||
import { labelRouter } from "./routers/label";
|
||||
import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
|
||||
import: importRouter,
|
||||
user: userRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -352,4 +352,40 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Check if a board slug is available",
|
||||
method: "GET",
|
||||
path: "/boards/{boardPublicId}/check-slug-availability",
|
||||
description: "Checks if a board slug is available",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
isReserved: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.boardSlug,
|
||||
input.boardPublicId,
|
||||
);
|
||||
return {
|
||||
isReserved: !isBoardSlugAvailable,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -578,14 +578,6 @@ export const cardRouter = createTRPCRouter({
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
@@ -597,7 +589,17 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
if (card.workspaceVisibility === "private") {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
}
|
||||
|
||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||
ctx.db,
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as boardRepo from "@kan/db/repository/board.repo";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as importRepo from "@kan/db/repository/import.repo";
|
||||
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
@@ -13,10 +14,9 @@ import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { apiKeys, urls } from "./integration";
|
||||
|
||||
const TRELLO_API_URL = "https://api.trello.com/1";
|
||||
|
||||
interface TrelloBoard {
|
||||
export interface TrelloBoard {
|
||||
id: string;
|
||||
name: string;
|
||||
labels: TrelloLabel[];
|
||||
@@ -42,10 +42,6 @@ interface TrelloCard {
|
||||
labels: TrelloLabel[];
|
||||
}
|
||||
|
||||
interface MemberData {
|
||||
idBoards: string[];
|
||||
}
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
trello: createTRPCRouter({
|
||||
getBoards: protectedProcedure
|
||||
@@ -53,46 +49,51 @@ export const importRouter = createTRPCRouter({
|
||||
openapi: {
|
||||
summary: "Get boards from Trello",
|
||||
method: "GET",
|
||||
path: "/imports/trello/boards",
|
||||
path: "/integrations/trello/boards",
|
||||
description: "Retrieves all boards from Trello",
|
||||
tags: ["Imports"],
|
||||
tags: ["Integrations"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().length(32),
|
||||
token: z.string().length(76),
|
||||
}),
|
||||
)
|
||||
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||
.query(async ({ input }) => {
|
||||
const fetchMemberRes = await fetch(
|
||||
`${TRELLO_API_URL}/tokens/${input.token}/member?key=${input.apiKey}`,
|
||||
.query(async ({ ctx }) => {
|
||||
const apiKey = apiKeys.trello;
|
||||
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
message: "Trello API key not found",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
"trello",
|
||||
);
|
||||
|
||||
const member = (await fetchMemberRes.json()) as MemberData;
|
||||
const token = integration?.accessToken;
|
||||
|
||||
const boardIds = member.idBoards;
|
||||
if (!token)
|
||||
throw new TRPCError({
|
||||
message: "Trello token not found",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const fetchBoard = async (boardId: string) => {
|
||||
const response = await fetch(
|
||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
|
||||
);
|
||||
const data = (await response.json()) as TrelloBoard;
|
||||
const response = await fetch(
|
||||
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
const data = (await response.json()) as TrelloBoard[];
|
||||
|
||||
const boards = [];
|
||||
|
||||
for (const boardId of boardIds) {
|
||||
boards.push(Promise.resolve(fetchBoard(boardId)));
|
||||
}
|
||||
|
||||
const boardDataArray = await Promise.all(boards);
|
||||
|
||||
return boardDataArray.map((board) => ({
|
||||
return data.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
}));
|
||||
@@ -102,7 +103,7 @@ export const importRouter = createTRPCRouter({
|
||||
openapi: {
|
||||
summary: "Import boards from Trello",
|
||||
method: "POST",
|
||||
path: "/imports/trello/import",
|
||||
path: "/imports/trello/boards",
|
||||
description: "Imports boards from Trello",
|
||||
tags: ["Imports"],
|
||||
protect: true,
|
||||
@@ -111,8 +112,6 @@ export const importRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
boardIds: z.array(z.string()),
|
||||
apiKey: z.string().length(32),
|
||||
token: z.string().length(76),
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
@@ -120,12 +119,32 @@ export const importRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
const apiKey = apiKeys.trello;
|
||||
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
message: "Trello API key not found",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
userId,
|
||||
"trello",
|
||||
);
|
||||
|
||||
if (!integration)
|
||||
throw new TRPCError({
|
||||
message: "Trello token not found",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
@@ -150,7 +169,7 @@ export const importRouter = createTRPCRouter({
|
||||
|
||||
for (const boardId of input.boardIds) {
|
||||
const response = await fetch(
|
||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}&lists=open&cards=open&labels=all`,
|
||||
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`,
|
||||
);
|
||||
const data = (await response.json()) as TrelloBoard;
|
||||
|
||||
|
||||
128
packages/api/src/routers/integration.ts
Normal file
128
packages/api/src/routers/integration.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const urls = {
|
||||
trello: "https://api.trello.com/1",
|
||||
};
|
||||
|
||||
export const apiKeys = {
|
||||
trello: process.env.TRELLO_APP_API_KEY,
|
||||
};
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
providers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integrations = await integrationsRepo.getProvidersForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return integrations;
|
||||
}),
|
||||
disconnect: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Disconnect integration",
|
||||
method: "POST",
|
||||
path: "/integration/disconnect",
|
||||
description: "Disconnects an integration",
|
||||
tags: ["Integration"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ provider: z.enum(["trello"]) }))
|
||||
.output(z.object({}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
input.provider,
|
||||
);
|
||||
|
||||
if (!integration)
|
||||
throw new TRPCError({
|
||||
message: "Integration not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await integrationsRepo.deleteProviderForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
input.provider,
|
||||
);
|
||||
|
||||
return {};
|
||||
}),
|
||||
getAuthorizationUrl: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get authorization URL for an integration",
|
||||
method: "GET",
|
||||
path: "/integration/authorize",
|
||||
description: "Retrieves the authorization URL for an integration",
|
||||
tags: ["Integration"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ provider: z.enum(["trello"]) }))
|
||||
.output(z.object({ url: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const apiKey = apiKeys[input.provider];
|
||||
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} API key not set in environment variables`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
input.provider,
|
||||
);
|
||||
|
||||
if (integration)
|
||||
throw new TRPCError({
|
||||
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} integration already exists`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
if (input.provider === "trello") {
|
||||
const url = `${urls[input.provider]}/authorize?key=${apiKey}&expiration=never&response_type=token&scope=read&return_url=${env("NEXT_PUBLIC_BASE_URL")}/settings/trello/authorize&callback_method=fragment`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
message: "Invalid provider",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
@@ -224,10 +225,19 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const isWorkspaceSlugAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug);
|
||||
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
workspace.plan !== "pro" &&
|
||||
input.slug !== workspace.publicId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace slug cannot be changed in cloud without upgrading to a paid plan`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
reservedOrPremiumWorkspaceSlug?.type === "reserved" ||
|
||||
(workspace.plan !== "pro" &&
|
||||
reservedOrPremiumWorkspaceSlug?.type === "premium") ||
|
||||
!isWorkspaceSlugAvailable
|
||||
) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -6,7 +6,7 @@ import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { initAuth } from "@kan/auth";
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"default": "./src/client.ts"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"default": "./src/server.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
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 type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
@@ -11,13 +14,99 @@ import * as schema from "@kan/db/schema";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export const configuredProviders = socialProviderList.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
appBundleIdentifier?: string;
|
||||
tenantId?: string;
|
||||
requireSelectAccount?: boolean;
|
||||
clientKey?: string;
|
||||
issuer?: string;
|
||||
}
|
||||
>
|
||||
>((acc, provider) => {
|
||||
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||
acc[provider] = { clientId: id, clientSecret: secret };
|
||||
}
|
||||
if (
|
||||
provider === "apple" &&
|
||||
Object.keys(acc).includes("apple") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const bundleId =
|
||||
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||
if (bundleId && bundleId.length > 0) {
|
||||
acc[provider].appBundleIdentifier = bundleId;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "gitlab" &&
|
||||
Object.keys(acc).includes("gitlab") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||
if (issuer && issuer.length > 0) {
|
||||
acc[provider].issuer = issuer;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "microsoft" &&
|
||||
Object.keys(acc).includes("microsoft") &&
|
||||
acc[provider]
|
||||
) {
|
||||
acc[provider].tenantId = "common";
|
||||
acc[provider].requireSelectAccount = true;
|
||||
}
|
||||
if (
|
||||
provider === "tiktok" &&
|
||||
Object.keys(acc).includes("tiktok") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||
if (key && key.length > 0) {
|
||||
acc[provider].clientKey = key;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const socialProvidersPlugin = () => ({
|
||||
id: "social-providers-plugin",
|
||||
endpoints: {
|
||||
getSocialProviders: createAuthEndpoint(
|
||||
"/social-providers",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) =>
|
||||
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
async function downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export const initAuth = (db: dbClient) => {
|
||||
return betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_BASE_URL!,
|
||||
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
|
||||
: [],
|
||||
? [
|
||||
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||
]
|
||||
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
@@ -25,13 +114,22 @@ export const initAuth = (db: dbClient) => {
|
||||
user: schema.users,
|
||||
},
|
||||
}),
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
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",
|
||||
@@ -42,6 +140,7 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
socialProvidersPlugin(),
|
||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||
apiKey({ disableKeyHashing: true }),
|
||||
magicLink({
|
||||
@@ -64,13 +163,65 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
}),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before() {
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
async after(user) {
|
||||
if (
|
||||
user.image &&
|
||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||
) {
|
||||
try {
|
||||
const client = new S3Client({
|
||||
region: env("S3_REGION") ?? "",
|
||||
endpoint: env("S3_ENDPOINT") ?? "",
|
||||
credentials: {
|
||||
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
|
||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
const fileExtension =
|
||||
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||
|
||||
const imageBuffer = await downloadImage(user.image);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: key,
|
||||
Body: imageBuffer,
|
||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
await userRepo.update(db, user.id, {
|
||||
image: key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/get-session")) {
|
||||
const user = ctx.context.session?.user;
|
||||
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
user &&
|
||||
!user.stripeCustomerId
|
||||
) {
|
||||
|
||||
25
packages/auth/src/client.ts
Normal file
25
packages/auth/src/client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { BetterAuthClientPlugin } from "better-auth";
|
||||
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
||||
import { BetterFetchOption, createAuthClient } from "better-auth/react";
|
||||
|
||||
import { socialProvidersPlugin } from "./auth";
|
||||
|
||||
const socialProvidersPluginClient = {
|
||||
id: "social-providers-plugin",
|
||||
$InferServerPlugin: {} as ReturnType<typeof socialProvidersPlugin>,
|
||||
getActions: ($fetch) => {
|
||||
return {
|
||||
getSocialProviders: async (fetchOptions?: BetterFetchOption) => {
|
||||
const res = $fetch("/social-providers", {
|
||||
method: "GET",
|
||||
...fetchOptions,
|
||||
});
|
||||
return res.then((res) => res.data as string[]);
|
||||
},
|
||||
};
|
||||
},
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient(), apiKeyClient()],
|
||||
});
|
||||
@@ -1,6 +1 @@
|
||||
import { initAuth } from "./auth";
|
||||
import { authClient } from "./clients";
|
||||
|
||||
export const name = "auth";
|
||||
|
||||
export { initAuth, authClient };
|
||||
|
||||
1
packages/auth/src/server.ts
Normal file
1
packages/auth/src/server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { initAuth } from "./auth";
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/internal-package.json",
|
||||
"compilerOptions": {},
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_fromListId_list_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_toListId_list_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_labelId_label_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_commentId_card_comments_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_fromListId_list_id_fk" FOREIGN KEY ("fromListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_toListId_list_id_fk" FOREIGN KEY ("toListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "public"."label"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS "integration" (
|
||||
"provider" varchar(255) NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"accessToken" varchar(255) NOT NULL,
|
||||
"refreshToken" varchar(255),
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,152 @@
|
||||
ALTER TABLE "board" DROP CONSTRAINT "board_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "board" DROP CONSTRAINT "board_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card" DROP CONSTRAINT "card_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card" DROP CONSTRAINT "card_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "feedback" DROP CONSTRAINT "feedback_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "import" DROP CONSTRAINT "import_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "label" DROP CONSTRAINT "label_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "label" DROP CONSTRAINT "label_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "list" DROP CONSTRAINT "list_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "list" DROP CONSTRAINT "list_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_userId_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_createdBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace" DROP CONSTRAINT "workspace_deletedBy_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "board" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "card" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "card_comments" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "feedback" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "import" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "label" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "list" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "workspace" ALTER COLUMN "createdBy" DROP NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "board" ADD CONSTRAINT "board_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "board" ADD CONSTRAINT "board_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card" ADD CONSTRAINT "card_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card" ADD CONSTRAINT "card_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "import" ADD CONSTRAINT "import_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "label" ADD CONSTRAINT "label_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "label" ADD CONSTRAINT "label_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "list" ADD CONSTRAINT "list_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "list" ADD CONSTRAINT "list_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS "integration" (
|
||||
"provider" varchar(255) NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"accessToken" varchar(255) NOT NULL,
|
||||
"refreshToken" varchar(255),
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
2087
packages/db/migrations/meta/20250605101155_snapshot.json
Normal file
2087
packages/db/migrations/meta/20250605101155_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1941
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
1941
packages/db/migrations/meta/20250608100932_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1871
packages/db/migrations/meta/20250608175448_snapshot.json
Normal file
1871
packages/db/migrations/meta/20250608175448_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2087
packages/db/migrations/meta/20250610200449_snapshot.json
Normal file
2087
packages/db/migrations/meta/20250610200449_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,34 @@
|
||||
"when": 1748378293342,
|
||||
"tag": "20250527203813_AddDeletedAtToLabel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1749118315675,
|
||||
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1749405288761,
|
||||
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1749377372062,
|
||||
"tag": "20250608100932_AddIntegrationsTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1749585889984,
|
||||
"tag": "20250610200449_ReaddIntegrationTable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
@@ -239,6 +239,7 @@ export const getBySlug = async (
|
||||
workspace: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
@@ -476,3 +477,34 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const isBoardSlugAvailable = async (
|
||||
db: dbClient,
|
||||
boardSlug: string,
|
||||
boardPublicId: string,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.publicId, boardPublicId),
|
||||
exists(
|
||||
db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result.length === 0;
|
||||
};
|
||||
|
||||
@@ -731,6 +731,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
board: {
|
||||
columns: {
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -742,6 +743,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
? {
|
||||
id: result.id,
|
||||
workspaceId: result.list.board.workspaceId,
|
||||
workspaceVisibility: result.list.board.visibility,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
59
packages/db/src/repository/integration.repo.ts
Normal file
59
packages/db/src/repository/integration.repo.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { and, eq, gte } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
|
||||
export const isProviderAvailableForUser = async (
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
provider: string,
|
||||
) => {
|
||||
const integration = await db.query.integrations.findFirst({
|
||||
where: and(
|
||||
eq(integrations.userId, userId),
|
||||
eq(integrations.provider, provider),
|
||||
gte(integrations.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
return !!integration;
|
||||
};
|
||||
|
||||
export const getProviderForUser = async (
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
provider: string,
|
||||
) => {
|
||||
const integration = await db.query.integrations.findFirst({
|
||||
where: and(
|
||||
eq(integrations.userId, userId),
|
||||
eq(integrations.provider, provider),
|
||||
gte(integrations.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
return integration;
|
||||
};
|
||||
|
||||
export const getProvidersForUser = async (db: dbClient, userId: string) => {
|
||||
const integration = await db.query.integrations.findMany({
|
||||
where: and(
|
||||
eq(integrations.userId, userId),
|
||||
gte(integrations.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
return integration;
|
||||
};
|
||||
|
||||
export const deleteProviderForUser = async (
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
provider: string,
|
||||
) => {
|
||||
await db
|
||||
.delete(integrations)
|
||||
.where(
|
||||
and(eq(integrations.userId, userId), eq(integrations.provider, provider)),
|
||||
);
|
||||
};
|
||||
@@ -81,5 +81,6 @@ export const apiKeyRelations = relations(apikey, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [apikey.userId],
|
||||
references: [users.id],
|
||||
relationName: "apiKeyUser",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -33,13 +33,15 @@ export const boards = pgTable(
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
importId: bigint("importId", { mode: "number" }).references(
|
||||
() => imports.id,
|
||||
),
|
||||
@@ -60,19 +62,23 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [boards.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "boardCreatedByUser",
|
||||
}),
|
||||
lists: many(lists),
|
||||
labels: many(labels),
|
||||
deletedBy: one(users, {
|
||||
fields: [boards.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "boardDeletedByUser",
|
||||
}),
|
||||
import: one(imports, {
|
||||
fields: [boards.importId],
|
||||
references: [imports.id],
|
||||
relationName: "boardImport",
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [boards.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "boardWorkspace",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -44,13 +44,15 @@ export const cards = pgTable("card", {
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
index: integer("index").notNull(),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
listId: bigint("listId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => lists.id, { onDelete: "cascade" }),
|
||||
@@ -61,20 +63,24 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [cards.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "cardsCreatedByUser",
|
||||
}),
|
||||
list: one(lists, {
|
||||
fields: [cards.listId],
|
||||
references: [lists.id],
|
||||
relationName: "cardsList",
|
||||
}),
|
||||
deletedBy: one(users, {
|
||||
fields: [cards.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "cardsDeletedByUser",
|
||||
}),
|
||||
labels: many(cardsToLabels),
|
||||
members: many(cardToWorkspaceMembers),
|
||||
import: one(imports, {
|
||||
fields: [cards.importId],
|
||||
references: [imports.id],
|
||||
relationName: "cardsImport",
|
||||
}),
|
||||
comments: many(comments),
|
||||
activities: many(cardActivities),
|
||||
@@ -91,22 +97,28 @@ export const cardActivities = pgTable("card_activity", {
|
||||
toIndex: integer("toIndex"),
|
||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||
() => lists.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id),
|
||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id),
|
||||
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),
|
||||
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
|
||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||
toTitle: varchar("toTitle", { length: 255 }),
|
||||
fromDescription: text("fromDescription"),
|
||||
toDescription: text("toDescription"),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
commentId: bigint("commentId", { mode: "number" }).references(
|
||||
() => comments.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
@@ -116,34 +128,42 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
card: one(cards, {
|
||||
fields: [cardActivities.cardId],
|
||||
references: [cards.id],
|
||||
relationName: "cardActivitiesCard",
|
||||
}),
|
||||
fromList: one(lists, {
|
||||
fields: [cardActivities.fromListId],
|
||||
references: [lists.id],
|
||||
relationName: "cardActivitiesFromList",
|
||||
}),
|
||||
toList: one(lists, {
|
||||
fields: [cardActivities.toListId],
|
||||
references: [lists.id],
|
||||
relationName: "cardActivitiesToList",
|
||||
}),
|
||||
label: one(labels, {
|
||||
fields: [cardActivities.labelId],
|
||||
references: [labels.id],
|
||||
relationName: "cardActivitiesLabel",
|
||||
}),
|
||||
workspaceMember: one(workspaceMembers, {
|
||||
fields: [cardActivities.workspaceMemberId],
|
||||
references: [workspaceMembers.id],
|
||||
relationName: "cardActivitiesWorkspaceMember",
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [cardActivities.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "cardActivitiesUser",
|
||||
}),
|
||||
member: one(workspaceMembers, {
|
||||
fields: [cardActivities.workspaceMemberId],
|
||||
references: [workspaceMembers.id],
|
||||
relationName: "cardActivitiesMember",
|
||||
}),
|
||||
comment: one(comments, {
|
||||
fields: [cardActivities.commentId],
|
||||
references: [comments.id],
|
||||
relationName: "cardActivitiesComment",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -152,7 +172,7 @@ export const cardsToLabels = pgTable(
|
||||
{
|
||||
cardId: bigint("cardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => cards.id),
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
labelId: bigint("labelId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => labels.id, { onDelete: "cascade" }),
|
||||
@@ -164,10 +184,12 @@ export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
|
||||
card: one(cards, {
|
||||
fields: [cardsToLabels.cardId],
|
||||
references: [cards.id],
|
||||
relationName: "cardToLabelsCard",
|
||||
}),
|
||||
label: one(labels, {
|
||||
fields: [cardsToLabels.labelId],
|
||||
references: [labels.id],
|
||||
relationName: "cardToLabelsLabel",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -176,7 +198,7 @@ export const cardToWorkspaceMembers = pgTable(
|
||||
{
|
||||
cardId: bigint("cardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => cards.id),
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||
@@ -190,10 +212,12 @@ export const cardToWorkspaceMembersRelations = relations(
|
||||
card: one(cards, {
|
||||
fields: [cardToWorkspaceMembers.cardId],
|
||||
references: [cards.id],
|
||||
relationName: "cardToWorkspaceMembersCard",
|
||||
}),
|
||||
member: one(workspaceMembers, {
|
||||
fields: [cardToWorkspaceMembers.workspaceMemberId],
|
||||
references: [workspaceMembers.id],
|
||||
relationName: "cardToWorkspaceMembersMember",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -205,26 +229,31 @@ export const comments = pgTable("card_comments", {
|
||||
cardId: bigint("cardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
|
||||
export const 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")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
url: text("url").notNull(),
|
||||
@@ -26,5 +26,6 @@ export const feedbackRelations = relations(feedback, ({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [feedback.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "feedbackCreatedByUser",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -26,9 +26,9 @@ export const imports = pgTable("import", {
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
source: importSourceEnum("source").notNull(),
|
||||
status: importStatusEnum("status").notNull(),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}).enableRLS();
|
||||
|
||||
@@ -36,6 +36,7 @@ export const importsRelations = relations(imports, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [imports.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "importsCreatedByUser",
|
||||
}),
|
||||
boards: many(boards),
|
||||
cards: many(cards),
|
||||
|
||||
@@ -7,4 +7,5 @@ export * from "./imports";
|
||||
export * from "./labels";
|
||||
export * from "./lists";
|
||||
export * from "./users";
|
||||
export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
|
||||
40
packages/db/src/schema/integrations.ts
Normal file
40
packages/db/src/schema/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
primaryKey,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { users } from "./users";
|
||||
|
||||
export const integrations = pgTable(
|
||||
"integration",
|
||||
{
|
||||
provider: varchar("provider", { length: 255 }).notNull(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessToken: varchar("accessToken", { length: 255 }).notNull(),
|
||||
refreshToken: varchar("refreshToken", { length: 255 }),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt")
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updatedAt").$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
name: "integration_pkey",
|
||||
columns: [table.userId, table.provider],
|
||||
}),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
export const integrationsRelations = relations(integrations, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [integrations.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -18,9 +18,9 @@ export const labels = pgTable("label", {
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
colourCode: varchar("colourCode", { length: 12 }),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
boardId: bigint("boardId", { mode: "number" })
|
||||
@@ -28,17 +28,21 @@ export const labels = pgTable("label", {
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
|
||||
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [labels.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "labelsCreatedByUser",
|
||||
}),
|
||||
deletedBy: one(users, {
|
||||
fields: [labels.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "labelsDeletedByUser",
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [labels.boardId],
|
||||
@@ -48,5 +52,6 @@ export const labelsRelations = relations(labels, ({ one, many }) => ({
|
||||
import: one(imports, {
|
||||
fields: [labels.importId],
|
||||
references: [imports.id],
|
||||
relationName: "labelsImport",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -19,13 +19,15 @@ export const lists = pgTable("list", {
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
index: integer("index").notNull(),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
boardId: bigint("boardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
@@ -36,18 +38,22 @@ export const listsRelations = relations(lists, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [lists.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "listsCreatedByUser",
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [lists.boardId],
|
||||
references: [boards.id],
|
||||
relationName: "listsBoard",
|
||||
}),
|
||||
cards: many(cards),
|
||||
deletedBy: one(users, {
|
||||
fields: [lists.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "listsDeletedByUser",
|
||||
}),
|
||||
import: one(imports, {
|
||||
fields: [lists.importId],
|
||||
references: [imports.id],
|
||||
relationName: "listsImport",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cards } from "./cards";
|
||||
import { imports } from "./imports";
|
||||
import { lists } from "./lists";
|
||||
import { workspaceMembers, workspaces } from "./workspaces";
|
||||
import { integrations } from "./integrations";
|
||||
|
||||
export const users = pgTable("user", {
|
||||
id: uuid("id")
|
||||
@@ -29,12 +30,33 @@ export const users = pgTable("user", {
|
||||
}).enableRLS();
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
boards: many(boards),
|
||||
cards: many(cards),
|
||||
deletedBoards: many(boards, {
|
||||
relationName: "boardDeletedByUser",
|
||||
}),
|
||||
boards: many(boards, {
|
||||
relationName: "boardCreatedByUser",
|
||||
}),
|
||||
deletedCards: many(cards, {
|
||||
relationName: "cardsDeletedByUser",
|
||||
}),
|
||||
cards: many(cards, {
|
||||
relationName: "cardsCreatedByUser",
|
||||
}),
|
||||
imports: many(imports),
|
||||
lists: many(lists),
|
||||
workspaces: many(workspaces),
|
||||
deletedLists: many(lists, {
|
||||
relationName: "listsDeletedByUser",
|
||||
}),
|
||||
lists: many(lists, {
|
||||
relationName: "listsCreatedByUser",
|
||||
}),
|
||||
deletedWorkspaces: many(workspaces, {
|
||||
relationName: "workspaceDeletedByUser",
|
||||
}),
|
||||
workspaces: many(workspaces, {
|
||||
relationName: "workspaceCreatedByUser",
|
||||
}),
|
||||
apiKeys: many(apikey),
|
||||
integrations: many(integrations),
|
||||
}));
|
||||
|
||||
export const usersToWorkspacesRelations = relations(
|
||||
@@ -43,18 +65,22 @@ export const usersToWorkspacesRelations = relations(
|
||||
addedBy: one(users, {
|
||||
fields: [workspaceMembers.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "usersToWorkspacesAddedByUser",
|
||||
}),
|
||||
deletedBy: one(users, {
|
||||
fields: [workspaceMembers.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "usersToWorkspacesDeletedByUser",
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [workspaceMembers.userId],
|
||||
references: [users.id],
|
||||
relationName: "usersToWorkspacesUser",
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [workspaceMembers.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "usersToWorkspacesWorkspace",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -36,17 +36,28 @@ export const workspaces = pgTable("workspace", {
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
|
||||
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||
user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
|
||||
user: one(users, {
|
||||
fields: [workspaces.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "workspaceCreatedByUser",
|
||||
}),
|
||||
deletedBy: one(users, {
|
||||
fields: [workspaces.deletedBy],
|
||||
references: [users.id],
|
||||
relationName: "workspaceDeletedByUser",
|
||||
}),
|
||||
members: many(workspaceMembers),
|
||||
boards: many(boards),
|
||||
}));
|
||||
@@ -55,7 +66,7 @@ export const workspaceMembers = pgTable("workspace_members", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
userId: uuid("userId").references(() => users.id),
|
||||
userId: uuid("userId").references(() => users.id, { onDelete: "set null" }),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
@@ -63,7 +74,9 @@ export const workspaceMembers = pgTable("workspace_members", {
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
role: memberRoleEnum("role").notNull(),
|
||||
status: memberStatusEnum("status").default("invited").notNull(),
|
||||
}).enableRLS();
|
||||
@@ -74,10 +87,12 @@ export const workspaceMembersRelations = relations(
|
||||
user: one(users, {
|
||||
fields: [workspaceMembers.userId],
|
||||
references: [users.id],
|
||||
relationName: "workspaceMembersUser",
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [workspaceMembers.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "workspaceMembersWorkspace",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.25",
|
||||
"nodemailer": "^7.0.3",
|
||||
"react-email": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kan/eslint-config": "workspace:*",
|
||||
"@kan/prettier-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||
import MagicLinkTemplate from "./templates/magic-link";
|
||||
import ResetPasswordTemplate from "./templates/reset-password";
|
||||
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||
|
||||
const emailTemplates: Record<Templates, React.FC> = {
|
||||
MAGIC_LINK: MagicLinkTemplate,
|
||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||
RESET_PASSWORD: ResetPasswordTemplate,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
export const sendEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
@@ -20,22 +33,17 @@ export const sendEmail = async (
|
||||
|
||||
const html = await render(<EmailTemplate {...data} />, { pretty: true });
|
||||
|
||||
const response = await fetch(process.env.EMAIL_URL!, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: process.env.EMAIL_FROM,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
}),
|
||||
});
|
||||
const options = {
|
||||
from: process.env.EMAIL_FROM,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send email: ${response.statusText}`);
|
||||
const response = await transporter.sendMail(options);
|
||||
|
||||
if (!response.accepted.length) {
|
||||
throw new Error(`Failed to send email: ${response.response}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
import * as React from "react";
|
||||
|
||||
export const JoinWorkspaceTemplate = ({
|
||||
@@ -90,7 +91,7 @@ export const JoinWorkspaceTemplate = ({
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_BASE_URL}
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
import * as React from "react";
|
||||
|
||||
export const MagicLinkTemplate = ({
|
||||
@@ -90,7 +91,7 @@ export const MagicLinkTemplate = ({
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_BASE_URL}
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
|
||||
108
packages/email/src/templates/reset-password.tsx
Normal file
108
packages/email/src/templates/reset-password.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Body } from "@react-email/body";
|
||||
import { Button } from "@react-email/button";
|
||||
import { Container } from "@react-email/container";
|
||||
import { Head } from "@react-email/head";
|
||||
import { Heading } from "@react-email/heading";
|
||||
import { Hr } from "@react-email/hr";
|
||||
import { Html } from "@react-email/html";
|
||||
import { Link } from "@react-email/link";
|
||||
import { Preview } from "@react-email/preview";
|
||||
import { Text } from "@react-email/text";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const ResetPasswordTemplate = ({
|
||||
resetPasswordUrl,
|
||||
resetPasswordToken,
|
||||
}: {
|
||||
resetPasswordUrl?: string;
|
||||
resetPasswordToken?: string;
|
||||
}) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Reset your Kan password</Preview>
|
||||
<Body style={{ backgroundColor: "white" }}>
|
||||
<Container
|
||||
style={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
||||
margin: "auto",
|
||||
paddingLeft: "0.75rem",
|
||||
paddingRight: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2.5rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
kan.bn
|
||||
</Heading>
|
||||
<Heading
|
||||
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
|
||||
>
|
||||
Reset your Kan password
|
||||
</Heading>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "2rem",
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
Click the button below to reset your password.
|
||||
</Text>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={resetPasswordUrl}
|
||||
style={{
|
||||
marginBottom: "2rem",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "#282828",
|
||||
paddingLeft: "1.5rem",
|
||||
paddingRight: "1.5rem",
|
||||
paddingTop: "1rem",
|
||||
paddingBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: "500",
|
||||
lineHeight: "1",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Reset your password
|
||||
</Button>
|
||||
<Text
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "#7e7e7e",
|
||||
}}
|
||||
>
|
||||
If you didn'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;
|
||||
1221
pnpm-lock.yaml
generated
1221
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user